@streamapp/stream 0.0.1
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 -0
- package/bin/stream.mjs +89 -0
- package/package.json +34 -0
- package/resources/app/dist-panel/panel-boards.js +6 -0
- package/resources/app/dist-panel/panel-detail.css +1 -0
- package/resources/app/dist-panel/panel-detail.js +2141 -0
- package/resources/app/dist-panel/panel-manage.css +1 -0
- package/resources/app/dist-panel/panel-manage.js +2246 -0
- package/resources/app/dist-panel/panel-movie.css +1 -0
- package/resources/app/dist-panel/panel-movie.js +2268 -0
- package/resources/app/dist-panel/panel-research.css +1 -0
- package/resources/app/dist-panel/panel-research.js +316 -0
- package/resources/app/dist-panel/panel.css +1 -0
- package/resources/app/dist-panel/panel.js +463 -0
- package/resources/dsh-engine-lock/package-lock.json +9032 -0
- package/resources/extension/.output/chrome-mv3/assets/popup-C2NUZZQW.css +2 -0
- package/resources/extension/.output/chrome-mv3/background.js +1 -0
- package/resources/extension/.output/chrome-mv3/chunks/popup-BeBts9v2.js +50 -0
- package/resources/extension/.output/chrome-mv3/icon/128.png +0 -0
- package/resources/extension/.output/chrome-mv3/icon/16.png +0 -0
- package/resources/extension/.output/chrome-mv3/icon/32.png +0 -0
- package/resources/extension/.output/chrome-mv3/icon/48.png +0 -0
- package/resources/extension/.output/chrome-mv3/icon/96.png +0 -0
- package/resources/extension/.output/chrome-mv3/manifest.json +1 -0
- package/resources/extension/.output/chrome-mv3/popup.html +21 -0
- package/resources/packages/1lou/1lou-search.recipe.json +102 -0
- package/resources/packages/1lou/README.md +7 -0
- package/resources/packages/1lou/package.json +15 -0
- package/resources/packages/Douyin_TikTok_Download_API/README.md +23 -0
- package/resources/packages/Douyin_TikTok_Download_API/activate.ts +14 -0
- package/resources/packages/Douyin_TikTok_Download_API/adapter/adapter.ts +321 -0
- package/resources/packages/Douyin_TikTok_Download_API/adapter/danmaku.ts +21 -0
- package/resources/packages/Douyin_TikTok_Download_API/adapter/executor.ts +75 -0
- package/resources/packages/Douyin_TikTok_Download_API/adapter/normalize.ts +20 -0
- package/resources/packages/Douyin_TikTok_Download_API/manifests.yaml +521 -0
- package/resources/packages/Douyin_TikTok_Download_API/package.json +32 -0
- package/resources/packages/alist/README.md +14 -0
- package/resources/packages/alist/activate.ts +12 -0
- package/resources/packages/alist/adapter.ts +74 -0
- package/resources/packages/alist/manifests.yaml +76 -0
- package/resources/packages/alist/mounts.ts +120 -0
- package/resources/packages/alist/package.json +24 -0
- package/resources/packages/alist/presets.ts +53 -0
- package/resources/packages/alist/provision.ts +84 -0
- package/resources/packages/baidu/baidu-share.recipe.json +93 -0
- package/resources/packages/baidu/package.json +16 -0
- package/resources/packages/baidu-search/baidu-search.recipe.json +129 -0
- package/resources/packages/baidu-search/package.json +25 -0
- package/resources/packages/brave/brave-search.recipe.json +129 -0
- package/resources/packages/brave/package.json +25 -0
- package/resources/packages/browser/README.md +3 -0
- package/resources/packages/browser/adapter.ts +44 -0
- package/resources/packages/browser/manifests.yaml +20 -0
- package/resources/packages/browser/package.json +13 -0
- package/resources/packages/bt0/README.md +7 -0
- package/resources/packages/bt0/bt0-search.recipe.json +69 -0
- package/resources/packages/bt0/bt0-tlist.recipe.json +74 -0
- package/resources/packages/bt0/package.json +15 -0
- package/resources/packages/btbtla/README.md +6 -0
- package/resources/packages/btbtla/btbtla-detail.recipe.json +76 -0
- package/resources/packages/btbtla/btbtla-search.recipe.json +74 -0
- package/resources/packages/btbtla/package.json +15 -0
- package/resources/packages/builtin/README.md +8 -0
- package/resources/packages/builtin/manifests.yaml +487 -0
- package/resources/packages/builtin/package.json +14 -0
- package/resources/packages/douyin/README.md +39 -0
- package/resources/packages/douyin/douyin-collection.recipe.json +127 -0
- package/resources/packages/douyin/douyin-search.recipe.json +163 -0
- package/resources/packages/douyin/package.json +16 -0
- package/resources/packages/firecrawl/firecrawl-create-key.recipe.json +81 -0
- package/resources/packages/firecrawl/firecrawl-read-key.recipe.json +56 -0
- package/resources/packages/firecrawl/package.json +16 -0
- package/resources/packages/google/google-search.recipe.json +127 -0
- package/resources/packages/google/package.json +24 -0
- package/resources/packages/groq/groq-create-key.recipe.json +67 -0
- package/resources/packages/groq/package.json +16 -0
- package/resources/packages/huggingface/README.md +7 -0
- package/resources/packages/huggingface/huggingface-spaces.recipe.json +73 -0
- package/resources/packages/huggingface/package.json +15 -0
- package/resources/packages/imdb/imdb-chart.recipe.json +75 -0
- package/resources/packages/imdb/package.json +15 -0
- package/resources/packages/index.ts +15 -0
- package/resources/packages/iqiyi/README.md +10 -0
- package/resources/packages/iqiyi/iqiyi-cn-album.recipe.json +80 -0
- package/resources/packages/iqiyi/iqiyi-cn-search.recipe.json +71 -0
- package/resources/packages/iqiyi/package.json +15 -0
- package/resources/packages/lizhi/README.md +35 -0
- package/resources/packages/lizhi/lizhi-user.recipe.json +80 -0
- package/resources/packages/lizhi/package.json +15 -0
- package/resources/packages/manmanbuy/README.md +16 -0
- package/resources/packages/manmanbuy/manmanbuy-search.recipe.json +115 -0
- package/resources/packages/manmanbuy/package.json +15 -0
- package/resources/packages/mineru/README.md +38 -0
- package/resources/packages/mineru/package.json +28 -0
- package/resources/packages/pansou/README.md +14 -0
- package/resources/packages/pansou/activate.ts +11 -0
- package/resources/packages/pansou/adapter.ts +94 -0
- package/resources/packages/pansou/manifests.yaml +50 -0
- package/resources/packages/pansou/package.json +27 -0
- package/resources/packages/qq/package.json +15 -0
- package/resources/packages/qq/qq-send.recipe.json +104 -0
- package/resources/packages/quark/package.json +16 -0
- package/resources/packages/quark/quark-share.recipe.json +40 -0
- package/resources/packages/reddit/package.json +15 -0
- package/resources/packages/reddit/reddit-sub.recipe.json +77 -0
- package/resources/packages/replay/README.md +7 -0
- package/resources/packages/replay/package.json +13 -0
- package/resources/packages/rsshub/README.md +16 -0
- package/resources/packages/rsshub/manifests.yaml +107 -0
- package/resources/packages/rsshub/package.json +21 -0
- package/resources/packages/telegram/package.json +15 -0
- package/resources/packages/telegram/telegram-search.recipe.json +125 -0
- package/resources/packages/tencent/README.md +11 -0
- package/resources/packages/tencent/package.json +15 -0
- package/resources/packages/tencent/tencent-cn-episode.recipe.json +98 -0
- package/resources/packages/toubiec/README.md +38 -0
- package/resources/packages/toubiec/package.json +15 -0
- package/resources/packages/toubiec/toubiec-download.recipe.json +58 -0
- package/resources/packages/toubiec/toubiec-playlist.recipe.json +49 -0
- package/resources/packages/toubiec/toubiec-search.recipe.json +80 -0
- package/resources/packages/voiceprint/README.md +31 -0
- package/resources/packages/voiceprint/package.json +36 -0
- package/resources/packages/wikipedia/package.json +15 -0
- package/resources/packages/wikipedia/wikipedia-award-list.recipe.json +102 -0
- package/resources/packages/xhs/README.md +32 -0
- package/resources/packages/xhs/package.json +21 -0
- package/resources/packages/xhs/xhs-detail.recipe.json +129 -0
- package/resources/packages/xhs/xhs-home.recipe.json +144 -0
- package/resources/packages/xhs/xhs-like.recipe.json +96 -0
- package/resources/packages/xhs/xhs-search.recipe.json +138 -0
- package/resources/packages/xueqiu/README.md +9 -0
- package/resources/packages/xueqiu/package.json +16 -0
- package/resources/packages/xueqiu/xueqiu-detail.recipe.json +92 -0
- package/resources/packages/xueqiu/xueqiu-user.recipe.json +75 -0
- package/resources/packages/zhipu/package.json +16 -0
- package/resources/packages/zhipu/zhipu-create-key.recipe.json +74 -0
- package/resources/packages/zuna/README.md +31 -0
- package/resources/packages/zuna/package.json +15 -0
- package/resources/packages/zuna/zuna-album.recipe.json +46 -0
- package/resources/packages/zuna/zuna-download.recipe.json +53 -0
- package/resources/packages/zuna/zuna-playlist.recipe.json +46 -0
- package/resources/packages/zuna/zuna-search.recipe.json +96 -0
- package/resources/server.mjs +238876 -0
- package/resources/sidequest.jobs.js +1 -0
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# AList 的 source 声明。三条,分两类:
|
|
2
|
+
#
|
|
3
|
+
# **可订阅的 feed(discoverable:true)**——用户能在「添加来源」里挑到、加进一条订阅:
|
|
4
|
+
# - alist-audio 把某个网盘目录下的音频文件当成节目采进来。
|
|
5
|
+
#
|
|
6
|
+
# **内部能力(discoverable:false)**——它们不是 feed,只是被别的机制按 id 直接调用;
|
|
7
|
+
# 露在浏览目录里只会让人挑到一个加进订阅也不会产出条目的东西:
|
|
8
|
+
# - alist-list 供绑定向导选目录、增量同步 diff 网盘目录;
|
|
9
|
+
# - alist-resolve 声明 provides:[netdisk-file],作为 Provider 成员候选被选为播放兜底源
|
|
10
|
+
# (把网盘文件路径解析成临时直链;播放优先走网盘直链,官方源兜底)。
|
|
11
|
+
#
|
|
12
|
+
# 认证走 config alist_token / ALIST_TOKEN(adapter 内解析并注入 AlistClient),
|
|
13
|
+
# 故 manifest 的 auth 为 none——没有 per-request 的用户登录流。
|
|
14
|
+
- id: alist-list
|
|
15
|
+
adapter: alist
|
|
16
|
+
title: AList 目录浏览
|
|
17
|
+
description: 列出用户 AList 实例某路径下的文件;枚举网盘目录,供绑定向导选目录与增量同步 diff 新文件。
|
|
18
|
+
topics: [alist, 网盘, 目录, 文件, 枚举, netdisk, 夸克网盘, 阿里云盘, 百度网盘]
|
|
19
|
+
categories: [resource]
|
|
20
|
+
capabilities: [search]
|
|
21
|
+
auth: { type: none }
|
|
22
|
+
discoverable: false
|
|
23
|
+
cadence_hint_seconds: 3600
|
|
24
|
+
params_schema:
|
|
25
|
+
path:
|
|
26
|
+
type: string
|
|
27
|
+
required: false
|
|
28
|
+
description: AList 绝对路径,如 /夸克网盘/我的转存/某剧。默认根目录 /。
|
|
29
|
+
fixed_params: { mode: list }
|
|
30
|
+
- id: alist-resolve
|
|
31
|
+
adapter: alist
|
|
32
|
+
title: AList 直链解析
|
|
33
|
+
description: 把 AList 里的一个文件路径解析成可直连播放的临时直链(raw_url);作为播放兜底 Provider 成员,命中网盘映射时优先直链。
|
|
34
|
+
topics: [alist, 网盘, 直链, 播放, 解析, raw_url, netdisk, 后备, 兜底]
|
|
35
|
+
categories: [resource]
|
|
36
|
+
capabilities: [anchor]
|
|
37
|
+
auth: { type: none }
|
|
38
|
+
discoverable: false
|
|
39
|
+
cadence_hint_seconds: 3600
|
|
40
|
+
# provides 令本源出现在 netdisk-file 目标类型的 Provider 成员候选里(对齐 pansou 的
|
|
41
|
+
# provides:[search-download]);key_param=path 表明解析键就是网盘文件路径。
|
|
42
|
+
provides: [netdisk-file]
|
|
43
|
+
key_param: path
|
|
44
|
+
params_schema:
|
|
45
|
+
path:
|
|
46
|
+
type: string
|
|
47
|
+
required: true
|
|
48
|
+
description: AList 绝对文件路径,如 /夸克网盘/我的转存/某剧/01.mp4。
|
|
49
|
+
fixed_params: { mode: resolve }
|
|
50
|
+
- id: alist-audio
|
|
51
|
+
adapter: alist
|
|
52
|
+
# 这一条是**用户挑得到的入口**(唯一的 discoverable:true),文案照着「浏览目录里的一张卡」
|
|
53
|
+
# 写:说清它产出什么、什么时候该用它,不写实现口径。
|
|
54
|
+
# 不变量(不属于展示文案,所以留在注释里):与网盘**绑定**正交,不做去重——同一个文件既被
|
|
55
|
+
# 这条源采成条目、又被绑定配到某一集上时不会互相察觉;不重复靠用户在网盘侧只放该来源独有
|
|
56
|
+
# 的文件保证。
|
|
57
|
+
title: 网盘目录(音频)
|
|
58
|
+
description: 把网盘某个目录下的音频文件直接当成节目采进来——源站没有的那些(已下架、独家、自己转存的)走它。填一个目录路径即可,此后目录里新增的文件会在下次采集时进来。
|
|
59
|
+
topics: [alist, 网盘, 音频, 播客, 歌单, 目录, 下架, netdisk, 夸克网盘, 阿里云盘, 百度网盘]
|
|
60
|
+
categories: [resource]
|
|
61
|
+
capabilities: [search]
|
|
62
|
+
auth: { type: none }
|
|
63
|
+
discoverable: true
|
|
64
|
+
normalizer: alist
|
|
65
|
+
cadence_hint_seconds: 86400
|
|
66
|
+
params_schema:
|
|
67
|
+
path:
|
|
68
|
+
type: string
|
|
69
|
+
required: true
|
|
70
|
+
# widget 是**通用机制**:声明「这个字符串参数用哪种控件填」,前端 SourceParamField
|
|
71
|
+
# 照登记表挑控件(见那里的 PARAM_WIDGETS)。哪个网盘、哪条路径是站点知识,留在这里;
|
|
72
|
+
# 「可以用目录选择器填」是机制,留在前端。没有它这个参数就退成纯文本框,用户得手敲
|
|
73
|
+
# `/quark/来自:分享/播客付费节目合集/春典 JARGON` 这种路径。
|
|
74
|
+
widget: netdisk-dir
|
|
75
|
+
description: AList 绝对目录路径,如 /夸克网盘/怡乐下架。目录下的音频文件会被采成条目。
|
|
76
|
+
fixed_params: { mode: audio }
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import type { AlistStorage } from '../../src/netdisk/alist-client.ts'
|
|
2
|
+
import type { CookieCloudCookie } from '../../src/types.ts'
|
|
3
|
+
import { findPreset } from './presets.ts'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 挂载 reconciler —— 期望态在 Stream 侧(settings 持久化的 mounts 配置),
|
|
7
|
+
* AList 只是执行器。启动时/挂载后对比 storage/list:
|
|
8
|
+
* 缺 → create;被标 disabled(cookie 失效)→ 拉新 cookie update + enable;
|
|
9
|
+
* 不认识的 storage(用户手动在别处建的)一概不动。
|
|
10
|
+
* 幂等:连跑两遍第二遍零变更。
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** 期望态条目(settings.alist.mounts 持久化形态)。 */
|
|
14
|
+
export interface MountEntry {
|
|
15
|
+
presetId: string
|
|
16
|
+
/** 缺省用 preset.mountPath。 */
|
|
17
|
+
mountPath?: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** reconciler 只依赖 client 的 4 个 admin 方法——便于单测注入 mock。 */
|
|
21
|
+
export interface StorageAdmin {
|
|
22
|
+
listStorages(): Promise<AlistStorage[]>
|
|
23
|
+
createStorage(s: { mount_path: string; driver: string; addition: string }): Promise<void>
|
|
24
|
+
updateStorage(s: AlistStorage): Promise<void>
|
|
25
|
+
enableStorage(id: number): Promise<void>
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** cookie 来源(生产 = CookieCloudClient.fetch,单测注入固定表)。 */
|
|
29
|
+
export type CookieSource = () => Promise<Record<string, CookieCloudCookie[]>>
|
|
30
|
+
|
|
31
|
+
export interface ReconcileResult {
|
|
32
|
+
created: string[]
|
|
33
|
+
healed: string[]
|
|
34
|
+
/** 该域在 CookieCloud 里没 cookie —— UI 引导「用浏览器登录 xx」。 */
|
|
35
|
+
missingCookie: string[]
|
|
36
|
+
/** 已就绪无需动作。 */
|
|
37
|
+
ok: string[]
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** 单个 preset 的实时健康态(GET /api/netdisk/mounts 下发,UI 据此渲染)。 */
|
|
41
|
+
export type MountStatus = 'mounted' | 'error' | 'cookieReady' | 'noCookie'
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 由 AList storage 现状 + cookie 可得性推 preset 健康:
|
|
45
|
+
* 有 storage 且未禁用/状态正常 → mounted;
|
|
46
|
+
* 有 storage 但被禁用或状态异常(cookie 多半失效)→ error;
|
|
47
|
+
* 无 storage 但有 cookie → cookieReady(可自动挂载);
|
|
48
|
+
* 无 storage 且无 cookie → noCookie(待用扩展同步)。
|
|
49
|
+
*/
|
|
50
|
+
export function mountStatusOf(storage: AlistStorage | undefined, hasCookie: boolean): MountStatus {
|
|
51
|
+
if (storage) return !storage.disabled && (!storage.status || storage.status === 'work') ? 'mounted' : 'error'
|
|
52
|
+
return hasCookie ? 'cookieReady' : 'noCookie'
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* CookieCloud 域匹配:精确域、点前缀/子域变体,以及父域(`.quark.cn` 的 cookie
|
|
57
|
+
* 对 `pan.quark.cn` 生效 —— CookieCloud 常按裸父域存键)。
|
|
58
|
+
*/
|
|
59
|
+
export function cookiesForDomain(
|
|
60
|
+
all: Record<string, CookieCloudCookie[]>,
|
|
61
|
+
domain: string,
|
|
62
|
+
): CookieCloudCookie[] | undefined {
|
|
63
|
+
const direct = all[domain]
|
|
64
|
+
if (direct?.length) return direct
|
|
65
|
+
for (const [k, v] of Object.entries(all)) {
|
|
66
|
+
if (!v.length) continue
|
|
67
|
+
const nk = k.replace(/^\./, '')
|
|
68
|
+
// 子域/点变体(www.115.com ← 115.com),或父域覆盖(quark.cn → pan.quark.cn)
|
|
69
|
+
if (nk === domain || nk.endsWith(`.${domain}`) || domain.endsWith(`.${nk}`)) return v
|
|
70
|
+
}
|
|
71
|
+
return undefined
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function serializeCookies(cookies: CookieCloudCookie[]): string {
|
|
75
|
+
return cookies.map((c) => `${c.name}=${c.value}`).join('; ')
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function reconcileMounts(
|
|
79
|
+
desired: MountEntry[],
|
|
80
|
+
admin: StorageAdmin,
|
|
81
|
+
fetchCookies: CookieSource,
|
|
82
|
+
): Promise<ReconcileResult> {
|
|
83
|
+
const result: ReconcileResult = { created: [], healed: [], missingCookie: [], ok: [] }
|
|
84
|
+
if (desired.length === 0) return result // 无期望态就不拉 CookieCloud,省一次网络往返
|
|
85
|
+
|
|
86
|
+
const storages = await admin.listStorages()
|
|
87
|
+
const byPath = new Map(storages.map((s) => [s.mount_path, s]))
|
|
88
|
+
let cookieData: Record<string, CookieCloudCookie[]> | undefined // 惰性拉取,且只拉一次
|
|
89
|
+
|
|
90
|
+
for (const entry of desired) {
|
|
91
|
+
const preset = findPreset(entry.presetId)
|
|
92
|
+
if (!preset) continue // 未知 preset:配置陈旧,跳过不抛——不阻塞其余挂载
|
|
93
|
+
const mountPath = entry.mountPath ?? preset.mountPath
|
|
94
|
+
const existing = byPath.get(mountPath)
|
|
95
|
+
|
|
96
|
+
if (existing && !existing.disabled) {
|
|
97
|
+
result.ok.push(mountPath) // 已就绪:幂等第二遍全部走这条路径
|
|
98
|
+
continue
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
cookieData ??= await fetchCookies()
|
|
102
|
+
const cookies = cookiesForDomain(cookieData, preset.cookieDomain)
|
|
103
|
+
if (!cookies) {
|
|
104
|
+
result.missingCookie.push(mountPath)
|
|
105
|
+
continue
|
|
106
|
+
}
|
|
107
|
+
const addition = JSON.stringify({ ...preset.additionDefaults, cookie: serializeCookies(cookies) })
|
|
108
|
+
|
|
109
|
+
if (!existing) {
|
|
110
|
+
await admin.createStorage({ mount_path: mountPath, driver: preset.driver, addition })
|
|
111
|
+
result.created.push(mountPath)
|
|
112
|
+
} else {
|
|
113
|
+
// disabled = cookie 失效自愈路径:换 addition 后 enable
|
|
114
|
+
await admin.updateStorage({ ...existing, addition })
|
|
115
|
+
await admin.enableStorage(existing.id)
|
|
116
|
+
result.healed.push(mountPath)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return result
|
|
120
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@streamapp/alist",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"stream": {
|
|
7
|
+
"id": "alist",
|
|
8
|
+
"name": "AList",
|
|
9
|
+
"tagline": "网盘聚合,转存内容直链播放",
|
|
10
|
+
"description": "内置 AList 实例(支持夸克、阿里云盘、百度网盘等 40+ 存储), 把转存的播单内容变成可直链播放的后备源。播放优先走网盘直链,官方源兜底。 全默认配置:bootstrap 自动接管 admin 凭证,挂载网盘只需浏览器登录过(CookieCloud 同步)。 ALIST_URL / ALIST_TOKEN env 仅作指向外部实例的调试逃生门,不出现在 UI。",
|
|
11
|
+
"repository": "https://github.com/AlistGo/alist",
|
|
12
|
+
"backend": {
|
|
13
|
+
"image": "xhofe/alist:latest",
|
|
14
|
+
"service": "alist",
|
|
15
|
+
"port": 5244,
|
|
16
|
+
"health": "/ping",
|
|
17
|
+
"volumes": ["alist-data:/opt/alist/data"],
|
|
18
|
+
"standby": {
|
|
19
|
+
"idleMinutes": 240
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"code": { "entry": "./activate.ts", "adapters": ["alist"], "normalizers": ["alist"] }
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 挂载网盘 preset 表(v1 只列 cookie 登录型驱动)。
|
|
3
|
+
*
|
|
4
|
+
* 每个条目对应「挂载网盘」列表里的一个网盘:driver 对应 AList 驱动名,
|
|
5
|
+
* cookieDomain 是 CookieCloud 里的 cookie 域(用户在浏览器登录过即有),
|
|
6
|
+
* mountPath 是默认挂载点。addition 的字段 schema 不硬编码——UI 运行时从
|
|
7
|
+
* `GET /api/admin/driver/info?driver=xx` 拉取,这里只放 cookie 之外的必填默认值。
|
|
8
|
+
*/
|
|
9
|
+
export interface MountPreset {
|
|
10
|
+
/** preset 稳定 id(持久化到 mounts 配置里,不随 label 变)。 */
|
|
11
|
+
id: string
|
|
12
|
+
/** UI 展示名。 */
|
|
13
|
+
label: string
|
|
14
|
+
/** AList 驱动名(storage/create 的 driver 字段,原样传)。 */
|
|
15
|
+
driver: string
|
|
16
|
+
/** CookieCloud cookie_data 的键(域名,无协议前缀)。 */
|
|
17
|
+
cookieDomain: string
|
|
18
|
+
/** 默认挂载点。 */
|
|
19
|
+
mountPath: string
|
|
20
|
+
/** cookie 之外的 addition 默认值(JSON 序列化前的对象形态)。 */
|
|
21
|
+
additionDefaults: Record<string, unknown>
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const MOUNT_PRESETS: readonly MountPreset[] = [
|
|
25
|
+
{
|
|
26
|
+
id: '115',
|
|
27
|
+
label: '115 网盘',
|
|
28
|
+
driver: '115 Cloud',
|
|
29
|
+
cookieDomain: '115.com',
|
|
30
|
+
mountPath: '/115',
|
|
31
|
+
additionDefaults: { root_folder_id: '0' },
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
id: 'quark',
|
|
35
|
+
label: '夸克网盘',
|
|
36
|
+
driver: 'Quark',
|
|
37
|
+
cookieDomain: 'pan.quark.cn',
|
|
38
|
+
mountPath: '/quark',
|
|
39
|
+
additionDefaults: { root_folder_id: '0' },
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
id: 'uc',
|
|
43
|
+
label: 'UC 网盘',
|
|
44
|
+
driver: 'UC',
|
|
45
|
+
cookieDomain: 'drive.uc.cn',
|
|
46
|
+
mountPath: '/uc',
|
|
47
|
+
additionDefaults: { root_folder_id: '0' },
|
|
48
|
+
},
|
|
49
|
+
] as const
|
|
50
|
+
|
|
51
|
+
export function findPreset(id: string): MountPreset | undefined {
|
|
52
|
+
return MOUNT_PRESETS.find((p) => p.id === id)
|
|
53
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto'
|
|
2
|
+
import { execFile } from 'node:child_process'
|
|
3
|
+
import { promisify } from 'node:util'
|
|
4
|
+
|
|
5
|
+
const execFileP = promisify(execFile)
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Bootstrap 接管序列(决策 2026-07-08:内置托管是唯一形态)。
|
|
9
|
+
*
|
|
10
|
+
* AList 是实现细节:admin 密码由 Stream 生成并接管,用户永远不接触 AList UI/凭证。
|
|
11
|
+
* 序列(幂等):
|
|
12
|
+
* 1. 已有存储密码 → 直接 login 换新 48h JWT(跑两遍不重设密码);
|
|
13
|
+
* 2. login 失败(volume 重建导致密码漂移)→ 容器内 `alist admin set` 重设 → 再 login;
|
|
14
|
+
* 3. 无存储密码 → 生成随机密码 → admin set → login;
|
|
15
|
+
* 4. 密码 + token 持久化(settings.json alist 节点,与既有 token 同一安全域)。
|
|
16
|
+
*
|
|
17
|
+
* 调用方(bootstrap)负责就绪门控(health /ping 通过后才调用),本模块不做等待。
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export interface AlistCredentials {
|
|
21
|
+
password: string
|
|
22
|
+
token: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ProvisionDeps {
|
|
26
|
+
/** AList base url(内网地址,如插件网关或 http://alist:5244) */
|
|
27
|
+
baseUrl: string
|
|
28
|
+
/** 已存凭证(settings.json alist 节点) */
|
|
29
|
+
getStored: () => Partial<AlistCredentials>
|
|
30
|
+
/** 持久化凭证 */
|
|
31
|
+
save: (creds: AlistCredentials) => void
|
|
32
|
+
/** 容器内重设 admin 密码(默认 docker exec,测试注入 mock) */
|
|
33
|
+
execAdminSet: (password: string) => Promise<void>
|
|
34
|
+
fetchFn?: typeof fetch
|
|
35
|
+
genPassword?: () => string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** POST /api/auth/login → 48h JWT。AList 统一 {code,data:{token}} 信封。 */
|
|
39
|
+
export async function alistLogin(
|
|
40
|
+
baseUrl: string,
|
|
41
|
+
password: string,
|
|
42
|
+
fetchFn: typeof fetch = fetch,
|
|
43
|
+
): Promise<string> {
|
|
44
|
+
const r = await fetchFn(`${baseUrl.replace(/\/$/, '')}/api/auth/login`, {
|
|
45
|
+
method: 'POST',
|
|
46
|
+
headers: { 'content-type': 'application/json' },
|
|
47
|
+
body: JSON.stringify({ username: 'admin', password }),
|
|
48
|
+
})
|
|
49
|
+
const j = (await r.json()) as { code: number; message?: string; data?: { token?: string } }
|
|
50
|
+
if (!r.ok || j.code !== 200 || !j.data?.token)
|
|
51
|
+
throw new Error(`[alist] login 失败: ${j.code} ${j.message ?? ''}`)
|
|
52
|
+
return j.data.token
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** 宿主侧 docker exec 重设 admin 密码(xhofe/alist 镜像 workdir /opt/alist,二进制 ./alist)。 */
|
|
56
|
+
export function dockerAdminSet(service = 'alist'): (password: string) => Promise<void> {
|
|
57
|
+
return async (password) => {
|
|
58
|
+
await execFileP('docker', ['exec', service, './alist', 'admin', 'set', password])
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** 接管序列入口。返回可用 token;所有失败路径 throw(调用方决定降级策略)。 */
|
|
63
|
+
export async function provisionAlist(deps: ProvisionDeps): Promise<string> {
|
|
64
|
+
const fetchFn = deps.fetchFn ?? fetch
|
|
65
|
+
const gen = deps.genPassword ?? (() => randomBytes(18).toString('base64url'))
|
|
66
|
+
const stored = deps.getStored()
|
|
67
|
+
|
|
68
|
+
// 快路径:密码在手 → 只换 token,不动容器。
|
|
69
|
+
if (stored.password) {
|
|
70
|
+
try {
|
|
71
|
+
const token = await alistLogin(deps.baseUrl, stored.password, fetchFn)
|
|
72
|
+
deps.save({ password: stored.password, token })
|
|
73
|
+
return token
|
|
74
|
+
} catch {
|
|
75
|
+
// 密码漂移(volume 重建/手动改过)→ 落到重设路径,用存储密码保持稳定。
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const password = stored.password ?? gen()
|
|
80
|
+
await deps.execAdminSet(password)
|
|
81
|
+
const token = await alistLogin(deps.baseUrl, password, fetchFn)
|
|
82
|
+
deps.save({ password, token })
|
|
83
|
+
return token
|
|
84
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 1,
|
|
3
|
+
"kind": "http",
|
|
4
|
+
"sourceId": "baidu-share",
|
|
5
|
+
"acceptNonOk": true,
|
|
6
|
+
"jar": true,
|
|
7
|
+
"output": "object",
|
|
8
|
+
"request": {
|
|
9
|
+
"url": "https://pan.baidu.com/share/list?uk={uk}&shareid={shareid}&order=other&desc=1&showempty=0&web=1&page=1&num=100&root=1&clienttype=0",
|
|
10
|
+
"method": "GET",
|
|
11
|
+
"headers": {
|
|
12
|
+
"user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
|
|
13
|
+
"referer": "https://pan.baidu.com/s/{pwd_id}"
|
|
14
|
+
},
|
|
15
|
+
"redirect": "manual"
|
|
16
|
+
},
|
|
17
|
+
"compute": {
|
|
18
|
+
"capabilities": [],
|
|
19
|
+
"params": "(() => { const id = String(input.params.pwd_id || ''); return { surl: id.charAt(0) === '1' ? id.slice(1) : id, t: String(input.now * 1000) } })()",
|
|
20
|
+
"prefetch": [
|
|
21
|
+
{
|
|
22
|
+
"as": "first",
|
|
23
|
+
"request": {
|
|
24
|
+
"url": "https://pan.baidu.com/api/shorturlinfo?shorturl={pwd_id}&root=1&web=1&clienttype=0",
|
|
25
|
+
"method": "GET",
|
|
26
|
+
"headers": {
|
|
27
|
+
"user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
|
|
28
|
+
"referer": "https://pan.baidu.com/s/{pwd_id}"
|
|
29
|
+
},
|
|
30
|
+
"redirect": "manual"
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
"as": "landing",
|
|
35
|
+
"parse": "none",
|
|
36
|
+
"request": {
|
|
37
|
+
"url": "https://pan.baidu.com/s/{pwd_id}",
|
|
38
|
+
"method": "GET",
|
|
39
|
+
"headers": {
|
|
40
|
+
"user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
|
|
41
|
+
"referer": "https://pan.baidu.com/s/{pwd_id}"
|
|
42
|
+
},
|
|
43
|
+
"redirect": "manual"
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
"as": "verify",
|
|
48
|
+
"request": {
|
|
49
|
+
"url": "https://pan.baidu.com/share/verify?surl={surl}&t={t}&channel=chunlei&web=1&app_id=250528&clienttype=0",
|
|
50
|
+
"method": "POST",
|
|
51
|
+
"headers": {
|
|
52
|
+
"user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
|
|
53
|
+
"referer": "https://pan.baidu.com/s/{pwd_id}",
|
|
54
|
+
"content-type": "application/x-www-form-urlencoded"
|
|
55
|
+
},
|
|
56
|
+
"body": "pwd={passcode}&vcode=&vcode_str=",
|
|
57
|
+
"redirect": "manual"
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
"as": "info",
|
|
62
|
+
"request": {
|
|
63
|
+
"url": "https://pan.baidu.com/api/shorturlinfo?shorturl={pwd_id}&root=1&web=1&clienttype=0",
|
|
64
|
+
"method": "GET",
|
|
65
|
+
"headers": {
|
|
66
|
+
"user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
|
|
67
|
+
"referer": "https://pan.baidu.com/s/{pwd_id}"
|
|
68
|
+
},
|
|
69
|
+
"redirect": "manual"
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
],
|
|
73
|
+
"sign": "(() => { const ok = (o) => o && o.errno === 0 ? o : null; const src = ok(input.pre.info) || ok(input.pre.first) || {}; return { url: 'https://pan.baidu.com/share/list?uk=' + encodeURIComponent(String(src.uk || '')) + '&shareid=' + encodeURIComponent(String(src.shareid || '')) + '&order=other&desc=1&showempty=0&web=1&page=1&num=100&root=1&clienttype=0' } })()",
|
|
74
|
+
"decode": "(() => { const first = input.pre.first; if (!first || typeof first.errno !== 'number') return { validity: 'unknown', files: [], reason: '百度没有返回 JSON(可能是风控页)' }; if (first.errno === 140) return { validity: 'not-usable', files: [], reason: String(first.show_msg || '链接不存在') }; if (first.errno !== 0 && first.errno !== -9) return { validity: 'unknown', files: [], reason: 'shorturlinfo errno ' + first.errno }; const pwd = String(input.params.passcode || ''); if (first.errno === -9 && !pwd) return { validity: 'unknown', files: [], reason: '需要提取码,帖子里没给' }; if (pwd) { const v = input.pre.verify; if (!v || typeof v.errno !== 'number') return { validity: 'unknown', files: [], reason: '提取码校验没有返回 JSON' }; if (v.errno !== 0) return { validity: 'unknown', files: [], reason: v.errno === -9 ? '提取码不对' : '提取码校验 errno ' + v.errno }; const info = input.pre.info; if (!info || info.errno !== 0) return { validity: 'unknown', files: [], reason: '解锁后 shorturlinfo errno ' + (info && info.errno) } } const body = input.body; if (!body || typeof body.errno !== 'number') return { validity: 'unknown', files: [], reason: 'share/list 没有返回 JSON' }; if (body.errno !== 0) return { validity: 'unknown', files: [], reason: 'share/list errno ' + body.errno }; const files = (body.list || []).map((f) => ({ name: String(f.server_filename || ''), is_dir: f.isdir === 1 || f.isdir === '1', size: Number(f.size || 0) })); return files.length ? { validity: 'alive', files: files } : { validity: 'not-usable', files: [], reason: '分享里没有文件' } })()"
|
|
75
|
+
},
|
|
76
|
+
"assert": [{ "path": "validity", "desc": "baidu 探针判决必须带 validity" }],
|
|
77
|
+
"meta": {
|
|
78
|
+
"description": "一条百度网盘分享 → 是否存活 + 文件列表(带提取码匿名解锁;netdisk-verify-baidu Provider 行的成员)。百度几乎每条分享都锁着提取码,没有它只能判「链接是否存在」——看不进去 → unknown,绝不判死。",
|
|
79
|
+
"topics": ["baidu", "百度网盘", "网盘", "验活", "分享", "netdisk"],
|
|
80
|
+
"categories": ["resource"],
|
|
81
|
+
"capabilities": ["anchor"],
|
|
82
|
+
"facility": { "key": "baidu", "label": "百度网盘" },
|
|
83
|
+
"discoverable": false,
|
|
84
|
+
"_why_pick_in": "只在 Provider 成员面挑得到,不在频道订阅面——netdisk-verify-baidu 行的成员",
|
|
85
|
+
"pick_in": ["provider"],
|
|
86
|
+
"cadence_hint_seconds": 3600,
|
|
87
|
+
"key_param": "pwd_id",
|
|
88
|
+
"params_schema": {
|
|
89
|
+
"pwd_id": { "type": "string", "required": true, "description": "百度分享 id(pan.baidu.com/s/<pwd_id>)" },
|
|
90
|
+
"passcode": { "type": "string", "required": false, "default": "", "description": "提取码;没有则只能判断链接是否存在(看不进去 → unknown,绝不判死)" }
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@streamapp/baidu",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"author": "stream",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"stream-recipe"
|
|
7
|
+
],
|
|
8
|
+
"stream": {
|
|
9
|
+
"type": "recipe",
|
|
10
|
+
"_why_name": "叫「百度网盘」不叫「百度」:搜索那一份是隔壁的 packages/baidu-search(两件事只是域名同姓,见那份的 _why_own_package_not_packages_baidu)。面板上两条都摆着,名字不点明就只能靠 id 区分。",
|
|
11
|
+
"name": "百度网盘",
|
|
12
|
+
"tagline": "验分享链是否存活 + 列文件",
|
|
13
|
+
"facility": "baidu",
|
|
14
|
+
"schemaVersion": 1
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 1,
|
|
3
|
+
"kind": "browser",
|
|
4
|
+
"sourceId": "baidu-search",
|
|
5
|
+
"_why_allow_empty": "**「百度明确说未找到」是一个成功的 0 条,不是失败。** 这条腿的 0 条有两种来源,处置完全相反:百度换上那张 `.content_none`「抱歉,未找到相关结果。」的页 → 成功空(allowEmpty 收下,梯子据此告诉模型「确实没搜到」);条目选择器漂了 / 撞上安全验证 → 抛错(等待步自己判,见它的头注)。allowEmpty 在这里**不会**把「静默 0 条」放回来,因为漂移那条路根本走不到这里。",
|
|
6
|
+
"allowEmpty": true,
|
|
7
|
+
"_why_cookie_domain": "百度搜索不需要登录,这一格只是「这个包能走到哪些域」的申报(装载器拿它卡 goto 的落点,见 `src/replay/recipe-import.ts`)。填 baidu.com 以覆盖 www 子域。",
|
|
8
|
+
"cookieDomain": "baidu.com",
|
|
9
|
+
"_why_browser": "和 google / brave 那两条腿同一个理由:值钱的是**用户自己那个 Chrome**。百度在这条链路上补的不是冗余而是**中文长尾覆盖**——实测「怡楽播客 小宇宙」这类查询它回 10 条真结果(xiaoyuzhoufm 官方页、B 站、荔枝、蜻蜓、Apple 播客),而 Google/Brave 对这类中文小站覆盖差。它**不是 Google 的备胎**(那是 Brave),所以判据按语言分流,不按失败分流:查询里有 CJK 就和 Google 并行跑(`src/search/web-search-ladder.ts` 的 `pickBrowserLegs`)。",
|
|
10
|
+
"_why_entry_is_homepage": "entryUrl 是**首页**、搜索 URL 在 step#0 的 goto 上,这不是绕路:expect 的判据必须有区分力——动作前为假、动作后为真(browser-harvest/references/authoring.md §3.5)。实测首页 `#content_left` 与 `.content_none` 双双命中 0,任何一张结果页上至少命中 1,于是这个 expect 是真监督。直接把搜索 URL 当 entryUrl 的话,唯一能写的 expect 在动作之前就已经成立,引擎当场判它恒真(StepExpectVacuousError)。",
|
|
11
|
+
"entryUrl": "https://www.baidu.com/",
|
|
12
|
+
"entryWait": "load",
|
|
13
|
+
"_why_login_check": "这个源不需要登录,两个信号因此各自换了个更有用的意思:loggedIn 用搜索框 `input#kw`(实测首页和结果页都恰好 1 个,命中 = 我们拿到的是百度真页面)。**别照抄 google 那份的 `textarea[name=q]`**:百度的框是 `input#kw`。wall 是一组**没有实测到的**候选——这台机器上一次都没撞见百度的安全验证(`wappass.baidu.com` 那张「百度安全验证」页),所以只能按已知形状先摆着。**它没命中不会造成静默错**:真撞上时页面里既没有 `#content_left` 也没有 `.content_none`,等待步会当成「页面没到位」抛错(响,且会被检疫),只是错误信息说得没那么准。真撞见一次就回来把选择器改准。",
|
|
14
|
+
"loginCheck": {
|
|
15
|
+
"loggedIn": "input#kw",
|
|
16
|
+
"wall": ".passMod_dialog-container, #wrapper .vcode-body, form[action*=\"wappass\"]"
|
|
17
|
+
},
|
|
18
|
+
"_why_one_shot": "session.lifecycle = one-shot:查完就关。这条腿是对话里临时补一发搜索,不是持续采集——没有账本要留、没有 feed 要骑,留一个常驻标签只会白占用户浏览器里的一格。",
|
|
19
|
+
"_why_own_facility": "facility 是 `baidu-search` 而不是 `baidu`:`packages/baidu/` 那个包已经占着 `baidu`,它是**百度网盘**(`baidu-share`,打 pan.baidu.com 验分享链)。facility 是 lane / tab / 限速桶的归属键,两件事共用一个键就会互相牵连——理由写在 `package.json` 的 `_why_own_package_not_packages_baidu` 里。",
|
|
20
|
+
"session": {
|
|
21
|
+
"facility": "baidu-search",
|
|
22
|
+
"lifecycle": "one-shot",
|
|
23
|
+
"visibility": "unattended"
|
|
24
|
+
},
|
|
25
|
+
"steps": [
|
|
26
|
+
{
|
|
27
|
+
"_why": "{query} 由调用方按 URL 组件编码好再传进来(substitute 是纯文本替换,不编码)——查询词里一个 `#` 就会把 querystring 截断,而截断之后百度照样回一页结果,于是「搜错了」长得和「搜到了」一模一样。不加任何条数参数:实测一页 8–10 条自然结果,够 targetCount 取 10。",
|
|
28
|
+
"kind": "goto",
|
|
29
|
+
"url": "https://www.baidu.com/s?wd={query}",
|
|
30
|
+
"_why_expect_is_the_page_not_the_results": "这道闸门只判「我们拿到了一张真的百度结果页」,**不判它有没有结果**——两件事必须分开,否则「百度确实没有」和「选择器漂了」会合并成同一个失败,而那正是这条链路上最贵的一种混淆。实测三态(2026-08-13 活体量的):首页 `#content_left`=0 且 `.content_none`=0(所以这个 expect 非恒真);有结果页 `#content_left`=1;**未找到的页 `#content_left` 整个不存在,换成 `.content_none > .nors`「抱歉,未找到相关结果。」**。所以 union 命中 = 页面到了,两种 SERP 都算。**百度是第三种形状,别照抄另外两家**:Google 没结果时 `#rso` 消失、Brave 是容器还在换一张 `.fallback-snippet` 卡,百度是容器消失且另有一个具名的空态块。timeout 8s:实测渲染 2 秒出头。",
|
|
31
|
+
"expect": {
|
|
32
|
+
"selector": "#content_left, .content_none",
|
|
33
|
+
"timeout": 8000
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
"_why": "**这一步只做一件事:等结果列表不再长了。** 结果是渐进出现的,observer 早读一步就少读几条,而少读不会报错。所以在读之前先在页面里数着自然结果,连着 3 次 200ms 读数不变才算画完,封顶 3s。它不产 item(items 恒空),产出全在下面那个 dom observer 里。",
|
|
38
|
+
"_why_not_scroll_or_settle": "**这个等待刻意不用 settle,也不用 scroll。** settle 要「逼出一帧」(逐帧截图比字节),落到 `Page.captureScreenshot`,而对着后台标签这条 relay 命令在这台机器上稳定挂到 30s 超时(详见 browser-harvest/references/failure-atlas.md 4.3.6)。scroll 则是另一个理由:driver 今天一帧都不逼、滚动本身不贵了,但本步只是「等结果区 DOM 稳定」,滚动对它没有意义,白等一个 dwell。换成纯 `Runtime.evaluate` 的等待是几十毫秒。**判据:需要真帧的东西才配付这个价**,而「等 DOM 稳定」根本不需要帧——它是 Tier A。",
|
|
39
|
+
"_why_it_also_judges": "**这一步负责把「百度确实没有」和「我们读不到了」分开,而且必须分成「成功空」和「抛错」两种收场**——因为失败在这条链路上不只是一句话,**它带着检疫**:`RepairLedger` 见到一个源反复失败就把它隔离,之后 `ReplayAdapter.fetch` 直接 `return DECLINED`(`src/adapters/replay/adapter.ts`),**连浏览器都不开、也不报错**,表现就是这条腿静默死掉(2026-08-12 google 那条腿真撞过)。判据是对活体量出来的,分三档:(1) `.content_none` 在 = 百度明确说未找到 → **正常返回空**(成功的 0 条);(2) `#content_left` 也不在 = 页面根本没到位 / 撞上安全验证 → **抛错**;(3) `#content_left` 在却一条自然结果都读不到 → **抛错**(漂了,或整页只剩广告)。**注意百度极不愿意回 0 条**:实测随机串 `qzxjvwrtplmnbgh8827364` 它回 10 条模糊结果,加引号的精确匹配也被丢掉运算符照回结果——真正走到 `.content_none` 的是 `site:<不存在的域>`。所以第 (3) 档在实践中几乎只可能是漂移,判成抛错是对的。**字段也要在这里验一次**(第一张卡必须取得出 `mu` 属性和 h3):开了 `allowEmpty` 之后,observer 的**字段**选择器漂了会表现成「条目在、link 取不到 → 被 dedupe 丢光 → 0 条」,而 0 条现在是合法的成功——不在这一步拦住,它就变成一个安静的空答案。这一步和 observer 用同一批选择器不是重复:这里是**判据**(漂了就响),那里是**取数**。",
|
|
40
|
+
"kind": "evaluate",
|
|
41
|
+
"call": "async () => { const sel = \"#content_left > div[mu^='http']:not([mu*='nourl.ubs.baidu.com']):not([mu*='.recommend_list.baidu.com']):has(h3 a[href])\"; let last = -1, stable = 0; for (let i = 0; i < 15 && stable < 3; i++) { const c = document.querySelectorAll(sel).length; stable = (c > 0 && c === last) ? stable + 1 : 0; last = c; await new Promise(r => setTimeout(r, 200)); } const cards = document.querySelectorAll(sel); if (cards.length === 0) { if (document.querySelector('.content_none')) return { items: [] }; if (!document.querySelector('#content_left')) throw new Error('[baidu] 既没有结果容器 #content_left、也没有「抱歉,未找到相关结果」那张 .content_none —— 页面没到位或撞上了安全验证'); throw new Error('[baidu] 结果容器在,但一条自然结果都没读到 —— 条目选择器漂了(或整页只剩广告)'); } const first = cards[0]; if (!first.getAttribute('mu') || !first.querySelector('h3')) { throw new Error('[baidu] 读到了条目,但取不出标题或真实链接 —— 字段选择器漂了'); } return { items: [] }; }",
|
|
42
|
+
"itemsAt": "items",
|
|
43
|
+
"maxPages": 1
|
|
44
|
+
}
|
|
45
|
+
],
|
|
46
|
+
"observers": [
|
|
47
|
+
{
|
|
48
|
+
"_why_selectors": "2026-08-13 对活体重定过(三条查询:中文长尾「怡楽播客 小宇宙」、商业词「北京装修公司排名」、`site:<不存在的域>`)。条目单位是 `#content_left > div`,但**必须过滤**,因为百度的结果列里混着四类非自然结果:买词广告、相关搜索块、百度自己的问卷/AI 卡、以及 `recommend_list`。**过滤的支点是 `mu` 属性**——它是百度给每条自然结果标注的**真实目标 URL**,而**广告卡一律没有 `mu`**(实测商业词那页:4 张带「广告」角标的卡 + 3 个无 tpl 的推广块,`mu` 全为空;8 条自然结果 `mu` 全是真域名)。再排掉两个百度自留的假 URL 前缀:`nourl.ubs.baidu.com`(问卷卡 / AI 生成卡)和 `<数字>.recommend_list.baidu.com`(相关搜索)。最后 `:has(h3 a[href])` 排掉没有标题链接的块。**刻意不用 class**:`.result` / `.c-container` / `.new-pmd` 覆盖不准(`.result-op` 里既有百科这种真结果也有问卷卡),而卡片内部那些 `title-box_6uu5k` / `content-gap_3jlQr` 是编译期哈希、改一行样式就换一批。`mu` / `tpl` / `data-module` 是百度多年不动的语义属性。",
|
|
49
|
+
"_why_link_is_mu_not_the_redirect": "**link 直接读卡片自己的 `mu` 属性,不读 `h3 a` 的 href。** 那个 href 是 `http://www.baidu.com/link?url=…` 的中转链,要拿到真实 URL 得**多打一次请求**去跟重定向——而真实 URL 本来就明明白白写在同一张卡的 `mu` 上(实测逐条核对:`mu` 全是可直接打开的目标地址,如 `https://www.xiaoyuzhoufm.com/podcast/…`、`https://space.bilibili.com/…`)。**为什么不能把中转链直接甩给模型**:`web_search` 的下游是模型自己决定要不要 `read_url`,中转链会让每条结果多一跳、URL 里看不出是哪个站(模型据此判切题的能力当场作废),去重也会失效——实测同一条查询里两条**不同**结果的 `link?url=` 前缀完全一样,只有尾部不同,而 `mu` 一眼可分。**代价**:`mu` 是百度私有属性,它改名这条腿会 0 条——所以上面那个等待步显式验了第一张卡取不取得出 `mu`,漂了会抛错而不是安静地空。",
|
|
50
|
+
"_why_dom": "百度的结果是 SSR 进 HTML 的,页面不为它发 XHR——network observer 在这里没有东西可拦。",
|
|
51
|
+
"kind": "dom",
|
|
52
|
+
"itemSelector": "#content_left > div[mu^='http']:not([mu*='nourl.ubs.baidu.com']):not([mu*='.recommend_list.baidu.com']):has(h3 a[href])",
|
|
53
|
+
"fields": {
|
|
54
|
+
"title": {
|
|
55
|
+
"selector": "h3"
|
|
56
|
+
},
|
|
57
|
+
"link": {
|
|
58
|
+
"attr": "mu"
|
|
59
|
+
},
|
|
60
|
+
"snippet": {
|
|
61
|
+
"selector": "[data-module='abstract']"
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
"trigger": "final"
|
|
65
|
+
}
|
|
66
|
+
],
|
|
67
|
+
"output": {
|
|
68
|
+
"itemsAt": "items",
|
|
69
|
+
"_why_dedupe_link": "dom observer 产出的「原始条目」就是上面那张扁平卡片,所以 dedupeBy 走 link(= `mu`,页内唯一、也是这条结果的身份;中转链反而**不唯一**,见上面那段)。**注意这条路自己不会喊**:选择器漂了 → link 取不到 → 每条都因缺身份被丢 → 0 条,而 `allowEmpty` 开着,0 条是合法的成功。所以「漂了要响」这件事**不由这里保证**,由上面那个等待步的判据保证(它验条目数、也验第一张卡取不取得出 `mu` 和 h3)。两处的分工写在那一步的 `_why_it_also_judges` 里——改任何一边之前先读它。",
|
|
70
|
+
"dedupeBy": "link",
|
|
71
|
+
"targetCount": 10,
|
|
72
|
+
"mapping": {
|
|
73
|
+
"title": "title",
|
|
74
|
+
"link": "link",
|
|
75
|
+
"snippet": "snippet",
|
|
76
|
+
"guid": "link"
|
|
77
|
+
},
|
|
78
|
+
"assert": [
|
|
79
|
+
{
|
|
80
|
+
"path": "items",
|
|
81
|
+
"desc": "baidu #content_left 里一条自然结果都没读到 —— 选择器漂移或撞上了安全验证页"
|
|
82
|
+
}
|
|
83
|
+
]
|
|
84
|
+
},
|
|
85
|
+
"policy": {
|
|
86
|
+
"minActionIntervalMs": 500,
|
|
87
|
+
"maxTaskMs": 60000
|
|
88
|
+
},
|
|
89
|
+
"meta": {
|
|
90
|
+
"description": "百度网页搜索 — 在用户自己的 Chrome 里打开结果页,读 SSR 出来的自然结果(title/link/snippet),link 取卡片的 mu 属性即真实目标 URL。它补的是 web_search 的中文长尾覆盖",
|
|
91
|
+
"type": "post",
|
|
92
|
+
"title": "网页搜索",
|
|
93
|
+
"topics": [
|
|
94
|
+
"baidu",
|
|
95
|
+
"百度",
|
|
96
|
+
"搜索",
|
|
97
|
+
"search",
|
|
98
|
+
"web"
|
|
99
|
+
],
|
|
100
|
+
"categories": [
|
|
101
|
+
"news"
|
|
102
|
+
],
|
|
103
|
+
"facility": {
|
|
104
|
+
"key": "baidu-search",
|
|
105
|
+
"label": "百度搜索"
|
|
106
|
+
},
|
|
107
|
+
"capabilities": [
|
|
108
|
+
"search"
|
|
109
|
+
],
|
|
110
|
+
"provides": [
|
|
111
|
+
"search-content"
|
|
112
|
+
],
|
|
113
|
+
"key_param": "query",
|
|
114
|
+
"priority": 50,
|
|
115
|
+
"_why_not_discoverable": "不进发现列表:它不是一个「可订阅的信息源」(没人要一条定时跑的搜索),只是 web_search 借的一条腿。",
|
|
116
|
+
"discoverable": false,
|
|
117
|
+
"_why_pick_in": "只在 Provider 成员面挑得到,不在频道订阅面——web_search 第二档的一条腿",
|
|
118
|
+
"pick_in": ["provider"],
|
|
119
|
+
"cadence_hint_seconds": 300,
|
|
120
|
+
"homepage": "www.baidu.com",
|
|
121
|
+
"params_schema": {
|
|
122
|
+
"query": {
|
|
123
|
+
"type": "string",
|
|
124
|
+
"required": true,
|
|
125
|
+
"description": "搜索词,**已按 URL 组件编码**(调用方负责 encodeURIComponent)"
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@streamapp/baidu-search",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"author": "stream",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"stream-recipe"
|
|
7
|
+
],
|
|
8
|
+
"stream": {
|
|
9
|
+
"type": "recipe",
|
|
10
|
+
"_why_own_package_not_packages_baidu": "**百度网页搜索单独一个包、单独一个 facility,不并进 `packages/baidu/`**——那个包是**百度网盘**(`baidu-share`,facility 标签就叫「百度网盘」,kind:http 打 pan.baidu.com 验分享链)。两件事只是域名同姓:搜索不需要登录、网盘全靠登录态。合并的代价是实打实的:`stream.rateLimit` 是**按 facility 归并**的一个桶(`mergeRecipePackagesByFacility`),把「像人一样每分钟最多 10 次」压到 facility `baidu` 上,就等于顺手给网盘的批量验链装了个闸——那条路上没有人要这个闸,而且它坏起来是「批量验链忽然变慢」这种查不到源头的样子。",
|
|
11
|
+
"name": "百度",
|
|
12
|
+
"tagline": "网页搜索(中文长尾)",
|
|
13
|
+
"facility": "baidu-search",
|
|
14
|
+
"schemaVersion": 1,
|
|
15
|
+
"_why_cookieDomain": "百度搜索**不需要登录**,这一格填的不是「去哪儿取登录态」,而是「这个包能走到哪些域」——装载器拿它卡 goto 的落点(`src/replay/recipe-import.ts`:goto 的 host 必须落在 cookieDomain 之内,否则拒绝装载)。填 baidu.com 覆盖 www 子域。",
|
|
16
|
+
"cookieDomain": "baidu.com",
|
|
17
|
+
"_why_rateLimit": "和 google / brave 那两份同一个节奏、同一个理由:连着敲三次是正常的(burst 3),持续每分钟十次以上就不像人了(perMinute 10)。maxWaitMs 3000 是关键那条——这条腿长在对话热路径上(中文查询时和 Google **并行**跑),桶空了默认行为是「等」,等在对话里就是用户干瞪着 spinner;3 秒排不上就抛 RateLimitedError,梯子当场把这条腿当作不存在、Google 的结果照常给出去。",
|
|
18
|
+
"_why_no_perHour": "**故意不声明每小时累计预算**(google / brave 那两份有)。同一次实测里,Google 一小时约百发之后稳定被拦、同出口的 Brave 跟着弹人机验证,而**百度不受影响**——它没有表现出「按累计量拦人」这一维。往这里凭手感填一个数,等于用一个没有证据的闸门去卡一条正在正常工作的腿,而它坏起来的样子是「中文那条腿忽然常年缺席」,还不报错(梯子当它不存在)。哪天百度真撞出累计拦截,就在这里加 perHour 并把实测写进这段。",
|
|
19
|
+
"rateLimit": {
|
|
20
|
+
"burst": 3,
|
|
21
|
+
"perMinute": 10,
|
|
22
|
+
"maxWaitMs": 3000
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|