@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
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"manifest_version":3,"name":"Stream Companion","description":"Sync cookies into Stream and subscribe sources for the current page.","version":"0.1.0","icons":{"16":"icon/16.png","32":"icon/32.png","48":"icon/48.png","96":"icon/96.png","128":"icon/128.png"},"key":"MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA3J1pwzql3petxGDZQAVgvfUjO43NbudqqVzsGVajQ5u821WYwf91pmAh+noeJy4kaq7OV6wAtJYm82sstBrg4tEggBeUe1xG2uFPZfubB8Q1wVPRBVjD3w1ECtowz5VgCgGscLLqXXgfbGzYkzsa65k1aou6/7spu8evu/QShyaTVDLH0A8wO88xdYJI+/SqZhEG83Lgv3YAc6llZIDMd6BFS+Ts2vTFQjE6pfLIkbyfZQJq/n1/lKRNEitMf0ep0HVadZ9PY8FQ+7A4fMnvD+BJCxHuztwxW6mUI0BTFJspHiEWRgK9B4GtPrMbpu71aRBuUy2wuTKDD3rGEM/FvwIDAQAB","permissions":["cookies","storage","alarms","tabs","tabGroups","debugger","system.display","nativeMessaging"],"host_permissions":["<all_urls>"],"background":{"service_worker":"background.js"},"action":{"default_title":"Stream Companion","default_popup":"popup.html"}}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html class="dark">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>Stream Companion</title>
|
|
7
|
+
<style>
|
|
8
|
+
/* A browser-action popup sizes itself to the document root, NOT to an
|
|
9
|
+
overflowing descendant. The app shell is w-[340px], but unless html/body
|
|
10
|
+
declare that width the root chain stays width:auto → Chrome collapses the
|
|
11
|
+
popup to ~min and clips the shell into a tiny square. Pin the frame here. */
|
|
12
|
+
html, body { margin: 0; width: 340px; }
|
|
13
|
+
#root { width: 340px; }
|
|
14
|
+
</style>
|
|
15
|
+
<script type="module" crossorigin src="/chunks/popup-BeBts9v2.js"></script>
|
|
16
|
+
<link rel="stylesheet" crossorigin href="/assets/popup-C2NUZZQW.css">
|
|
17
|
+
</head>
|
|
18
|
+
<body>
|
|
19
|
+
<div id="root"></div>
|
|
20
|
+
</body>
|
|
21
|
+
</html>
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 1,
|
|
3
|
+
"kind": "html",
|
|
4
|
+
"sourceId": "1lou-search",
|
|
5
|
+
"request": {
|
|
6
|
+
"url": "https://www.1lou.me/search.htm?keyword={keyword}",
|
|
7
|
+
"method": "GET"
|
|
8
|
+
},
|
|
9
|
+
"list": {
|
|
10
|
+
"selector": "li.media.thread.tap:not(.hidden-sm)",
|
|
11
|
+
"limit": 50,
|
|
12
|
+
"fields": {
|
|
13
|
+
"title": {
|
|
14
|
+
"selector": "div.subject > a",
|
|
15
|
+
"text": true
|
|
16
|
+
},
|
|
17
|
+
"link": {
|
|
18
|
+
"selector": "div.subject > a",
|
|
19
|
+
"attr": "href",
|
|
20
|
+
"resolve": true
|
|
21
|
+
},
|
|
22
|
+
"guid": {
|
|
23
|
+
"selector": "div.subject > a",
|
|
24
|
+
"attr": "href",
|
|
25
|
+
"resolve": true
|
|
26
|
+
},
|
|
27
|
+
"pubDate": {
|
|
28
|
+
"selector": "span.date",
|
|
29
|
+
"text": true
|
|
30
|
+
},
|
|
31
|
+
"author": {
|
|
32
|
+
"selector": ".username",
|
|
33
|
+
"text": true
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
"detail": {
|
|
38
|
+
"urlFrom": "link",
|
|
39
|
+
"fields": {
|
|
40
|
+
"description": {
|
|
41
|
+
"selector": "div.message.break-all",
|
|
42
|
+
"html": true
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
"pagination": {
|
|
47
|
+
"mode": "increment",
|
|
48
|
+
"param": "page",
|
|
49
|
+
"start": 1,
|
|
50
|
+
"step": 1,
|
|
51
|
+
"maxPages": 1
|
|
52
|
+
},
|
|
53
|
+
"assert": [
|
|
54
|
+
{
|
|
55
|
+
"selector": "li.media.thread.tap",
|
|
56
|
+
"desc": "1lou search list rows present"
|
|
57
|
+
}
|
|
58
|
+
],
|
|
59
|
+
"meta": {
|
|
60
|
+
"normalizer": "rsshub",
|
|
61
|
+
"type": "post",
|
|
62
|
+
"description": "BT 之家 1LOU 站 — 关键词搜索",
|
|
63
|
+
"topics": [
|
|
64
|
+
"1lou",
|
|
65
|
+
"BT之家",
|
|
66
|
+
"影视",
|
|
67
|
+
"磁力",
|
|
68
|
+
"网盘",
|
|
69
|
+
"搜索",
|
|
70
|
+
"movie",
|
|
71
|
+
"bt"
|
|
72
|
+
],
|
|
73
|
+
"categories": [
|
|
74
|
+
"multimedia"
|
|
75
|
+
],
|
|
76
|
+
"facility": {
|
|
77
|
+
"key": "1lou",
|
|
78
|
+
"label": "BT 之家 1LOU"
|
|
79
|
+
},
|
|
80
|
+
"capabilities": [
|
|
81
|
+
"search"
|
|
82
|
+
],
|
|
83
|
+
"key_param": "keyword",
|
|
84
|
+
"priority": 80,
|
|
85
|
+
"discoverable": true,
|
|
86
|
+
"cadence_hint_seconds": 3600,
|
|
87
|
+
"auth": {
|
|
88
|
+
"type": "none"
|
|
89
|
+
},
|
|
90
|
+
"homepage": "1lou.me",
|
|
91
|
+
"params_schema": {
|
|
92
|
+
"keyword": {
|
|
93
|
+
"type": "string",
|
|
94
|
+
"required": true,
|
|
95
|
+
"description": "搜索关键词(片名)"
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
"radar": [
|
|
99
|
+
"1lou.me/search.htm"
|
|
100
|
+
]
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
|
|
2
|
+
## 说明(迁自 manifests.yaml 注释)
|
|
3
|
+
|
|
4
|
+
1lou(BT 之家 1LOU 站)关键词搜索 — kind:html recipe(服务端渲染 HTML,无 API、无浏览器)。
|
|
5
|
+
迁自本地 RSSHub 路由 lib/routes/1lou/search.ts。选择器按 2026-07 活体页面重新定过(RSSHub 那套
|
|
6
|
+
div.subject/a.username/attachlist 已随站点改版失效,未照抄):列表标题/链接/日期/作者 + 逐条进
|
|
7
|
+
详情页取正文(下载链接/影片信息都在正文里;种子 attachlist 已失效且网盘帖本就无种子,故不抓 enclosure)。
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Douyin_TikTok_Download_API
|
|
2
|
+
|
|
3
|
+
The reference external-backend plugin. ONE adapter (id: `Douyin_TikTok_Download_API`) fronts three
|
|
4
|
+
facilities (douyin/bilibili/tiktok) behind one video-service backend. Routing is DECLARATIVE per
|
|
5
|
+
source via `api`:
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
api: { endpoint, query:{<upstream>:{from,default,required}}, unwrap } ← generic executor
|
|
9
|
+
api: { handler: <name> } ← escape hatch
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
See the adding-external-api-plugin skill. No per-source `mode` param — it was an adapter routing key
|
|
13
|
+
mistakenly exposed to users; the `api` binding carries routing now.
|
|
14
|
+
|
|
15
|
+
## backend
|
|
16
|
+
|
|
17
|
+
dev 块已退役(2026-07-22):这个后端实际是生产 API,源码在 `~/Code/homelab/services/video-service`,
|
|
18
|
+
镜像本地 build(`docker build -t ghcr.io/jaggerh/video-service:latest .`)。之前骑着 dev 形态当生产用
|
|
19
|
+
(bind-mount + 每次重建 pip install + reload 白烧 CPU),现已转正跑 baked 镜像。
|
|
20
|
+
要改源码:改完重新 build 镜像 + `docker compose up -d` 即生效;高频迭代期可临时加回 dev 块。
|
|
21
|
+
|
|
22
|
+
`standby.idleMinutes: 30` —— 采集型冷调用:harvest tick / 用户点开视频才来一发,单次几秒内完事,
|
|
23
|
+
没有播放挂载那种「随时要来」的时效压力——200MiB 常驻不值得。30 分钟覆盖同一会话内的连续操作。
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { ActivateFn } from '../../src/packages/activate.ts'
|
|
2
|
+
import { DouyinTiktokDownloadApiAdapter } from './adapter/adapter.ts'
|
|
3
|
+
import { douyinNormalizer } from '../../src/content/douyin.ts'
|
|
4
|
+
|
|
5
|
+
/** 这个包贡献的东西:一个 adapter + 一个 normalizer。
|
|
6
|
+
* `config.baseUrl` 只装宿主读到的**显式**值(config.yaml / env),不装解析后的快照——
|
|
7
|
+
* host 档下 loopback origin 是容器醒着才存在的,构造期快照必得空串(见 bootstrap 的头注)。
|
|
8
|
+
* 未配则 undefined,adapter 自己惰性解析。 */
|
|
9
|
+
export const activate: ActivateFn = (ctx) => ({
|
|
10
|
+
adapters: {
|
|
11
|
+
Douyin_TikTok_Download_API: new DouyinTiktokDownloadApiAdapter(ctx.config.baseUrl as string | undefined),
|
|
12
|
+
},
|
|
13
|
+
normalizers: { douyin: douyinNormalizer },
|
|
14
|
+
})
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
import type { Adapter, AdapterSidecar } from '../../../src/adapters/types.ts'
|
|
2
|
+
import type { SourceManifest } from '../../../src/manifest/types.ts'
|
|
3
|
+
import type { DouyinAweme, DouyinAwemeListData, DouyinItem, DouyinResponse } from '../../../src/adapters/douyin/types.ts'
|
|
4
|
+
import type { BiliResponse } from '../../../src/adapters/bilibili/types.ts'
|
|
5
|
+
import type { TiktokResponse } from '../../../src/adapters/tiktok/types.ts'
|
|
6
|
+
import { pluginTarget } from '../../../src/plugins/plugin-target.ts'
|
|
7
|
+
import { withAwake } from '../../../src/plugins/standby/hook.ts'
|
|
8
|
+
import { DOUYIN_UNAVAILABLE_PREFIX, DOUYIN_SERVICE } from '../../../src/adapters/douyin/proxy.ts'
|
|
9
|
+
import { isDeclarative, runDeclarative, type Normalizer } from './executor.ts'
|
|
10
|
+
import { toDouyinItem } from './normalize.ts'
|
|
11
|
+
import { parseDanmakuXml } from './danmaku.ts'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The single adapter for the Douyin_TikTok_Download_API plugin — a thin HTTP client over
|
|
15
|
+
* the EXTERNAL, already-running video-service backend that fronts three facilities
|
|
16
|
+
* (douyin / bilibili / tiktok) behind one image. Stream does NOT own that process'
|
|
17
|
+
* lifecycle (no sidecar): it just calls it over HTTP at `baseUrl`.
|
|
18
|
+
*
|
|
19
|
+
* Routing is DECLARATIVE, not code: each source declares its `api` binding in the
|
|
20
|
+
* manifest — either `{endpoint, query, unwrap}` run by the generic executor, or
|
|
21
|
+
* `{handler: <name>}` for logic a declaration can't express (multi-step, pagination,
|
|
22
|
+
* conditional routing, browser sidecar, injected-credential query). This replaces the
|
|
23
|
+
* old three-adapter `switch(params.mode)` design (see the adding-external-api-plugin skill).
|
|
24
|
+
*
|
|
25
|
+
* Login cookies for the personalized feeds are host-injected through the standard
|
|
26
|
+
* credential seam (manifest `auth: cookie:<domain>` → CredentialResolver → init/sidecar
|
|
27
|
+
* env), never resolved here. douyin.com → DOUYIN_COOKIE, tiktok.com → TIKTOK_COOKIE.
|
|
28
|
+
*/
|
|
29
|
+
/** server-side fetch base (NOT the client-facing gateway path): explicit → DOUYIN_API_URL env
|
|
30
|
+
* → the bootstrap-injected plugin target (compose mode: container DNS; none: unset → ''). */
|
|
31
|
+
export function resolveApiUrl(explicit?: string): string {
|
|
32
|
+
return explicit ?? process.env.DOUYIN_API_URL ?? pluginTarget(DOUYIN_SERVICE) ?? ''
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Named normalizers a declarative source may reference via `api.normalize`. bilibili/tiktok
|
|
36
|
+
* return raw items (their presenter reads them); douyin bridges each aweme to StreamItem. */
|
|
37
|
+
const NORMALIZERS: Record<string, Normalizer> = { douyin: (raw) => toDouyinItem(raw as DouyinAweme) }
|
|
38
|
+
|
|
39
|
+
export class DouyinTiktokDownloadApiAdapter implements Adapter {
|
|
40
|
+
readonly id = 'Douyin_TikTok_Download_API'
|
|
41
|
+
private readonly explicitUrl?: string
|
|
42
|
+
/** host-injected login cookies, per facility domain (freshest from CookieCloud → env). */
|
|
43
|
+
private douyinCookie = ''
|
|
44
|
+
private tiktokCookie = ''
|
|
45
|
+
|
|
46
|
+
/** @param baseUrl EXPLICIT video-service base (user-configured external instance, e.g.
|
|
47
|
+
* http://10.0.0.21:3007) — leave unset to resolve the Stream-managed plugin target lazily. */
|
|
48
|
+
constructor(baseUrl?: string) {
|
|
49
|
+
this.explicitUrl = baseUrl
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** 惰性:host 档下 origin 是容器醒着时才存在的(standby Cell 缓存),构造期快照必得空串。
|
|
53
|
+
* 每次求值现解析;fetch 都在 withAwake 回调里,求值时容器已醒。compose 档恒定,无行为差。 */
|
|
54
|
+
private get baseUrl(): string {
|
|
55
|
+
return resolveApiUrl(this.explicitUrl).replace(/\/$/, '')
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async init(env: Record<string, string>): Promise<void> {
|
|
59
|
+
if (env.DOUYIN_COOKIE) this.douyinCookie = env.DOUYIN_COOKIE
|
|
60
|
+
if (env.TIKTOK_COOKIE) this.tiktokCookie = env.TIKTOK_COOKIE
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** No process to own — the video-service runs as its own container. The sidecar seam is
|
|
64
|
+
* used only to receive the freshest login cookies for the personalized HTTP feeds. */
|
|
65
|
+
sidecar: AdapterSidecar = {
|
|
66
|
+
start: async (creds) => {
|
|
67
|
+
if (creds.DOUYIN_COOKIE) this.douyinCookie = creds.DOUYIN_COOKIE
|
|
68
|
+
if (creds.TIKTOK_COOKIE) this.tiktokCookie = creds.TIKTOK_COOKIE
|
|
69
|
+
},
|
|
70
|
+
health: async () => true,
|
|
71
|
+
shutdown: async () => {},
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async fetch(params: Record<string, unknown>, manifest: SourceManifest): Promise<unknown[]> {
|
|
75
|
+
const api = manifest.api
|
|
76
|
+
if (isDeclarative(api)) {
|
|
77
|
+
return runDeclarative(api, params, (path, query) => this.get(path, query), NORMALIZERS)
|
|
78
|
+
}
|
|
79
|
+
if (api && 'handler' in api) return this.dispatch(api.handler, params)
|
|
80
|
+
throw new Error(`[Douyin_TikTok_Download_API] source "${manifest.id}" has no api binding`)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
private dispatch(handler: string, params: Record<string, unknown>): Promise<unknown[]> {
|
|
84
|
+
switch (handler) {
|
|
85
|
+
case 'douyin-user': return this.douyinUser(params)
|
|
86
|
+
case 'douyin-follow': return this.douyinFollow(params)
|
|
87
|
+
case 'bilibili-comments': return this.bilibiliComments(params)
|
|
88
|
+
case 'bilibili-danmaku': return this.bilibiliDanmaku(params)
|
|
89
|
+
case 'tiktok-comments': return this.tiktokComments(params)
|
|
90
|
+
case 'tiktok-user-collect': return this.tiktokUserCollect(params)
|
|
91
|
+
default: throw new Error(`[Douyin_TikTok_Download_API] unknown handler "${handler}"`)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ------------------------------------------------------------------ douyin handlers
|
|
96
|
+
/** user mode accepts a profile `url` (resolved to sec_user_id) or a direct `sec_user_id`. */
|
|
97
|
+
private async douyinUser(params: Record<string, unknown>): Promise<unknown[]> {
|
|
98
|
+
const secUserId = await this.resolveSecUserId(params)
|
|
99
|
+
if (!secUserId) throw new Error('[douyin] user mode needs `sec_user_id` or `url`')
|
|
100
|
+
return this.awemeList('/api/douyin/web/fetch_user_post_videos', {
|
|
101
|
+
sec_user_id: secUserId,
|
|
102
|
+
max_cursor: '0',
|
|
103
|
+
count: Number(params.count ?? 20),
|
|
104
|
+
})
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
private async douyinFollow(params: Record<string, unknown>): Promise<unknown[]> {
|
|
108
|
+
const query: Record<string, string | number> = { max_cursor: '0', count: Number(params.count ?? 20) }
|
|
109
|
+
if (this.douyinCookie) query.cookie = this.douyinCookie
|
|
110
|
+
return this.awemeList('/api/douyin/web/fetch_follow_feed', query)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ---------------------------------------------------------------- bilibili handlers
|
|
114
|
+
private async bilibiliComments(params: Record<string, unknown>): Promise<unknown[]> {
|
|
115
|
+
const bvId = String(params.bv_id ?? '')
|
|
116
|
+
if (!bvId) throw new Error('[bilibili] comments needs `bv_id`')
|
|
117
|
+
const pn = Number(params.pn ?? 1)
|
|
118
|
+
const rpid = params.rpid ? String(params.rpid) : ''
|
|
119
|
+
const path = rpid ? '/api/bilibili/web/fetch_comment_reply' : '/api/bilibili/web/fetch_video_comments'
|
|
120
|
+
const query: Record<string, string | number> = rpid ? { bv_id: bvId, pn, rpid } : { bv_id: bvId, pn }
|
|
121
|
+
// video-service 透传 bilibili 官方 envelope → 内层再包一层 data (同其它 bilibili 端点).
|
|
122
|
+
const res = await this.get<BiliResponse<{ data?: { replies?: unknown[] } }>>(path, query)
|
|
123
|
+
return Array.isArray(res.data?.data?.replies) ? res.data.data.replies : []
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
private async bilibiliDanmaku(params: Record<string, unknown>): Promise<unknown[]> {
|
|
127
|
+
const bvId = String(params.bv_id ?? '')
|
|
128
|
+
if (!bvId) throw new Error('[bilibili] danmaku needs `bv_id`')
|
|
129
|
+
// fetch_one_video wraps bilibili's official envelope → the cid sits at data.data.cid.
|
|
130
|
+
const detail = await this.get<BiliResponse<{ data?: { cid?: number } }>>('/api/bilibili/web/fetch_one_video', { bv_id: bvId })
|
|
131
|
+
const cid = detail.data?.data?.cid
|
|
132
|
+
if (!cid) return []
|
|
133
|
+
const xml = await this.getText('/api/bilibili/web/fetch_video_danmaku', { cid })
|
|
134
|
+
return parseDanmakuXml(xml)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ------------------------------------------------------------------ tiktok handlers
|
|
138
|
+
private async tiktokComments(params: Record<string, unknown>): Promise<unknown[]> {
|
|
139
|
+
const awemeId = String(params.aweme_id ?? '')
|
|
140
|
+
if (!awemeId) throw new Error('[tiktok] comments needs `aweme_id`')
|
|
141
|
+
const commentId = params.comment_id ? String(params.comment_id) : ''
|
|
142
|
+
const path = commentId ? '/api/tiktok/web/fetch_post_comment_reply' : '/api/tiktok/web/fetch_post_comment'
|
|
143
|
+
const query: Record<string, string | number> = commentId
|
|
144
|
+
? { item_id: awemeId, comment_id: commentId, cursor: Number(params.cursor ?? 0) }
|
|
145
|
+
: { aweme_id: awemeId, cursor: Number(params.cursor ?? 0) }
|
|
146
|
+
// ⚠️ unwrap PROVISIONAL — not curl-verified (needs a real aweme_id); video-service may
|
|
147
|
+
// wrap tiktok's envelope like bilibili's (data.data.*). Re-verify with real data.
|
|
148
|
+
const res = await this.get<TiktokResponse<{ comments?: unknown[] }>>(path, query)
|
|
149
|
+
return Array.isArray(res.data?.comments) ? res.data.comments : []
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
private async tiktokUserCollect(params: Record<string, unknown>): Promise<unknown[]> {
|
|
153
|
+
const secUid = String(params.sec_uid ?? '')
|
|
154
|
+
if (!secUid) throw new Error('[tiktok] user-collect needs `sec_uid`')
|
|
155
|
+
if (!this.tiktokCookie) throw new Error('[tiktok] user-collect mode needs a login cookie — declare auth: cookie:tiktok.com (CookieCloud) or set TIKTOK_COOKIE')
|
|
156
|
+
// this endpoint (unlike user-like) takes the cookie as an explicit query param.
|
|
157
|
+
// ⚠️ unwrap PROVISIONAL — not curl-verified (needs a login cookie). Re-verify.
|
|
158
|
+
const res = await this.get<TiktokResponse<{ itemList?: unknown[] }>>('/api/tiktok/web/fetch_user_collect', {
|
|
159
|
+
cookie: this.tiktokCookie,
|
|
160
|
+
secUid,
|
|
161
|
+
cursor: Number(params.cursor ?? 0),
|
|
162
|
+
count: Number(params.count ?? 30),
|
|
163
|
+
})
|
|
164
|
+
return Array.isArray(res.data?.itemList) ? res.data.itemList : []
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ------------------------------------------------------------------ follow (douyin)
|
|
168
|
+
async follow(userId: string): Promise<void> {
|
|
169
|
+
if (!userId) throw new Error('[douyin] userId required for follow')
|
|
170
|
+
const r = await withAwake(DOUYIN_SERVICE, () =>
|
|
171
|
+
fetch(`${this.baseUrl}/douyin/follow`, {
|
|
172
|
+
method: 'POST',
|
|
173
|
+
headers: { 'Content-Type': 'application/json' },
|
|
174
|
+
body: JSON.stringify({ user_id: userId }),
|
|
175
|
+
signal: AbortSignal.timeout(15000),
|
|
176
|
+
})
|
|
177
|
+
)
|
|
178
|
+
if (!r.ok) throw new Error(`[douyin] follow failed: HTTP ${r.status}`)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async unfollow(userId: string): Promise<void> {
|
|
182
|
+
if (!userId) throw new Error('[douyin] userId required for unfollow')
|
|
183
|
+
const r = await withAwake(DOUYIN_SERVICE, () =>
|
|
184
|
+
fetch(`${this.baseUrl}/douyin/unfollow`, {
|
|
185
|
+
method: 'POST',
|
|
186
|
+
headers: { 'Content-Type': 'application/json' },
|
|
187
|
+
body: JSON.stringify({ user_id: userId }),
|
|
188
|
+
signal: AbortSignal.timeout(15000),
|
|
189
|
+
})
|
|
190
|
+
)
|
|
191
|
+
if (!r.ok) throw new Error(`[douyin] unfollow failed: HTTP ${r.status}`)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ------------------------------------------------------------------ douyin helpers
|
|
195
|
+
private async resolveSecUserId(params: Record<string, unknown>): Promise<string> {
|
|
196
|
+
const direct = params.sec_user_id ? String(params.sec_user_id) : ''
|
|
197
|
+
if (direct) return direct
|
|
198
|
+
const url = params.url ? String(params.url) : ''
|
|
199
|
+
if (!url) return ''
|
|
200
|
+
const res = await this.get<DouyinResponse<string>>('/api/douyin/web/get_sec_user_id', { url })
|
|
201
|
+
return res.data ?? ''
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Call a douyin list endpoint and return its awemes normalized. Feed/user/collection put
|
|
205
|
+
* them under `data.aweme_list`; general-search returns a result array under `data.data`,
|
|
206
|
+
* where each element may wrap the aweme in `aweme_info` (search) or `aweme` (follow feed). */
|
|
207
|
+
private async awemeList(path: string, query: Record<string, string | number>): Promise<DouyinItem[]> {
|
|
208
|
+
const res = await this.get<DouyinResponse<DouyinAwemeListData & { data?: unknown[] }>>(path, query)
|
|
209
|
+
const d = res.data
|
|
210
|
+
if (!d) return []
|
|
211
|
+
const raw = (Array.isArray(d.aweme_list) ? d.aweme_list : Array.isArray(d.data) ? d.data : []) as unknown[]
|
|
212
|
+
return raw
|
|
213
|
+
.map((it) => {
|
|
214
|
+
if (it && typeof it === 'object') {
|
|
215
|
+
if ('aweme_info' in it) return (it as { aweme_info?: DouyinAweme }).aweme_info
|
|
216
|
+
if ('aweme' in it) return (it as { aweme?: DouyinAweme }).aweme
|
|
217
|
+
}
|
|
218
|
+
return it as DouyinAweme
|
|
219
|
+
})
|
|
220
|
+
.filter((a): a is DouyinAweme => !!a && typeof a === 'object' && typeof a.aweme_id === 'string')
|
|
221
|
+
.map(toDouyinItem)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ------------------------------------------------------------------ http
|
|
225
|
+
/** Resolve a douyin share/video url to its direct CDN play_addr (no-watermark) + the
|
|
226
|
+
* Referer the CDN gate demands. The route range-proxies this url so the <video> streams +
|
|
227
|
+
* seeks (vs the old whole-file buffer via /api/download). Returns null if unresolvable.
|
|
228
|
+
*
|
|
229
|
+
* play_addr path calibrated 2026-07-06 against live video-service: the first url_list entry
|
|
230
|
+
* is a `*.douyinvod.com` direct mp4 (Range → 206, but only with Referer https://www.douyin.com/,
|
|
231
|
+
* else 403); the last entry is the www.douyin.com/aweme/v1/play fallback. */
|
|
232
|
+
async resolveVideoPlayAddr(url: string): Promise<{ url: string; headers: Record<string, string> } | null> {
|
|
233
|
+
// The video is gone/private ⇒ /api/hybrid/video_data has no aweme_detail to build from and
|
|
234
|
+
// throws a bare "HTTP 400: An error occurred" (a public, live video would return a play_addr).
|
|
235
|
+
// Before surfacing that opaque 400, ask douyin directly why: fetch_one_video returns a HTTP-200
|
|
236
|
+
// envelope whose `filter_detail.detail_msg` is the platform's own user-facing reason (「作品不见了」
|
|
237
|
+
// / 已删除 / 私密). Rethrow THAT so the debug box says something actionable instead of "400".
|
|
238
|
+
let r: { data?: { video?: { play_addr?: { url_list?: string[] } } } }
|
|
239
|
+
try {
|
|
240
|
+
r = await this.get('/api/hybrid/video_data', { url, minimal: 'false' })
|
|
241
|
+
} catch (e) {
|
|
242
|
+
const reason = await this.douyinUnavailableReason(url)
|
|
243
|
+
throw reason ? new Error(reason) : (e as Error)
|
|
244
|
+
}
|
|
245
|
+
const play = r.data?.video?.play_addr?.url_list?.find((u) => /^https?:\/\//.test(u))
|
|
246
|
+
if (play) return { url: play, headers: { Referer: 'https://www.douyin.com/' } }
|
|
247
|
+
// Reachable + parsed but no playable url — same "why?" probe; only fall through to null when
|
|
248
|
+
// douyin has no complaint (genuinely-empty video_data, not a filtered work).
|
|
249
|
+
const reason = await this.douyinUnavailableReason(url)
|
|
250
|
+
if (reason) throw new Error(reason)
|
|
251
|
+
return null
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Best-effort: ask douyin why a video didn't resolve. When a work is deleted/private, its
|
|
255
|
+
* fetch_one_video envelope is HTTP-200 with `aweme_detail: null` and a `filter_detail` carrying
|
|
256
|
+
* the platform's own zh message. Returns that as a user-facing reason, or '' if we can't tell
|
|
257
|
+
* (no extractable aweme_id, the probe itself failed, or the work is actually fine). */
|
|
258
|
+
private async douyinUnavailableReason(url: string): Promise<string> {
|
|
259
|
+
// matches both douyin.com/video/<id> and iesdouyin.com/share/video/<id> (the latter contains /video/).
|
|
260
|
+
const awemeId = url.match(/\/video\/(\d{6,})/)?.[1] ?? ''
|
|
261
|
+
if (!awemeId) return ''
|
|
262
|
+
try {
|
|
263
|
+
const res = await this.get<{ data?: { aweme_detail?: unknown; filter_detail?: { detail_msg?: string; notice?: string } } }>(
|
|
264
|
+
'/api/douyin/web/fetch_one_video', { aweme_id: awemeId }
|
|
265
|
+
)
|
|
266
|
+
const fd = res.data?.filter_detail
|
|
267
|
+
if (res.data?.aweme_detail == null && fd) {
|
|
268
|
+
const msg = String(fd.detail_msg || fd.notice || '').trim()
|
|
269
|
+
return `${DOUYIN_UNAVAILABLE_PREFIX}${msg ? `:${msg}` : '(已删除或设为私密)'}`
|
|
270
|
+
}
|
|
271
|
+
} catch { /* probe is best-effort — fall back to the caller's original error */ }
|
|
272
|
+
return ''
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
private async get<T>(path: string, query: Record<string, string | number>): Promise<T> {
|
|
276
|
+
// The failure signatures we must name precisely (else a red dot says only "fetch
|
|
277
|
+
// failed" / "HTTP 4xx" / an empty list): (1) network — fetch() rejects, enriched with
|
|
278
|
+
// its .cause at capture (see classifyError); (2) HTTP 4xx — expired/missing cookie is a
|
|
279
|
+
// 401/403/422; (3) HTTP 200 with a non-200 envelope `code` — the upstream's own error
|
|
280
|
+
// (risk-control / login required), which otherwise slips through as a silent empty feed.
|
|
281
|
+
// withAwake:host 档睡着的容器先唤醒;URL(含 baseUrl getter)在回调里现拼,醒来才有值。
|
|
282
|
+
const r = await withAwake(DOUYIN_SERVICE, () => fetch(this.urlFor(path, query)))
|
|
283
|
+
if (!r.ok) throw new Error(this.httpError(path, r.status, await r.text().catch(() => '')))
|
|
284
|
+
const text = await r.text()
|
|
285
|
+
let json: unknown
|
|
286
|
+
try {
|
|
287
|
+
json = JSON.parse(text)
|
|
288
|
+
} catch {
|
|
289
|
+
throw new Error(`[Douyin_TikTok_Download_API] ${path} 上游返回非 JSON(疑似风控拦截或登录页): ${text.slice(0, 200)}`)
|
|
290
|
+
}
|
|
291
|
+
const code = (json as { code?: unknown }).code
|
|
292
|
+
if (typeof code === 'number' && code !== 200) {
|
|
293
|
+
const msg = String((json as { message?: unknown }).message ?? (json as { detail?: unknown }).detail ?? '')
|
|
294
|
+
const authy = code === 401 || code === 403 || /login|登录|cookie|risk|风控|verify/i.test(msg)
|
|
295
|
+
const hint = authy ? '(登录态失效,请刷新 cookie)' : ''
|
|
296
|
+
throw new Error(`[Douyin_TikTok_Download_API] ${path} 上游错误 code=${code}${hint}${msg ? `: ${msg}` : ''}`)
|
|
297
|
+
}
|
|
298
|
+
return json as T
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
private async getText(path: string, query: Record<string, string | number>): Promise<string> {
|
|
302
|
+
const r = await withAwake(DOUYIN_SERVICE, () => fetch(this.urlFor(path, query)))
|
|
303
|
+
if (!r.ok) throw new Error(this.httpError(path, r.status, await r.text().catch(() => '')))
|
|
304
|
+
return r.text()
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
private urlFor(path: string, query: Record<string, string | number>): string {
|
|
308
|
+
const qs = new URLSearchParams()
|
|
309
|
+
for (const [k, v] of Object.entries(query)) if (v !== '') qs.set(k, String(v))
|
|
310
|
+
return `${this.baseUrl}${path}?${qs.toString()}`
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** A non-2xx from the video-service. 401/403/422 almost always means the login cookie is
|
|
314
|
+
* missing or expired, so say so; otherwise include a body snippet for the real reason. */
|
|
315
|
+
private httpError(path: string, status: number, body: string): string {
|
|
316
|
+
const hint = status === 401 || status === 403 || status === 422
|
|
317
|
+
? '(登录态失效或缺少 cookie,请刷新 CookieCloud)' : ''
|
|
318
|
+
const snippet = body ? `: ${body.slice(0, 200)}` : ''
|
|
319
|
+
return `[Douyin_TikTok_Download_API] ${path} → HTTP ${status}${hint}${snippet}`
|
|
320
|
+
}
|
|
321
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
function decodeXmlEntities(s: string): string {
|
|
2
|
+
return s
|
|
3
|
+
.replace(/</g, '<')
|
|
4
|
+
.replace(/>/g, '>')
|
|
5
|
+
.replace(/"/g, '"')
|
|
6
|
+
.replace(/'/g, "'")
|
|
7
|
+
.replace(/&/g, '&') // must run last — otherwise it double-decodes e.g. &lt; → <
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Parse bilibili's danmaku XML (`<d p="time,mode,size,color,...">text</d>`) into a
|
|
11
|
+
* flat list. `p`'s first field is the send-time offset in seconds. */
|
|
12
|
+
export function parseDanmakuXml(xml: string): Array<{ time: number; text: string }> {
|
|
13
|
+
const out: Array<{ time: number; text: string }> = []
|
|
14
|
+
const re = /<d p="([^"]*)">([^<]*)<\/d>/g
|
|
15
|
+
let match: RegExpExecArray | null
|
|
16
|
+
while ((match = re.exec(xml))) {
|
|
17
|
+
const time = Number(match[1].split(',')[0])
|
|
18
|
+
if (Number.isFinite(time)) out.push({ time, text: decodeXmlEntities(match[2]) })
|
|
19
|
+
}
|
|
20
|
+
return out
|
|
21
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { ApiBinding, ApiQueryParam } from '../../../src/manifest/types.ts'
|
|
2
|
+
|
|
3
|
+
/** The declarative arm of ApiBinding (endpoint/query/unwrap/normalize) — handler bindings
|
|
4
|
+
* are dispatched separately by the adapter. */
|
|
5
|
+
export type DeclApiBinding = Extract<ApiBinding, { endpoint: string }>
|
|
6
|
+
|
|
7
|
+
/** Maps a raw upstream item into the bridge shape makeStreamItem reads (guid/title/link/…).
|
|
8
|
+
* bilibili/tiktok return raw items (their presenter reads them); douyin registers one. */
|
|
9
|
+
export type Normalizer = (raw: unknown) => unknown
|
|
10
|
+
|
|
11
|
+
/** True for the declarative arm (vs a named handler). */
|
|
12
|
+
export function isDeclarative(api: ApiBinding | undefined): api is DeclApiBinding {
|
|
13
|
+
return !!api && 'endpoint' in api
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Resolve a binding's `query` map into concrete upstream query values.
|
|
18
|
+
* - value = params[from] ?? default
|
|
19
|
+
* - required && blank → throw
|
|
20
|
+
* - blank (undefined / '') → omit the key (matches the old adapters' skip-empty behavior)
|
|
21
|
+
*/
|
|
22
|
+
export function resolveQuery(
|
|
23
|
+
query: Record<string, ApiQueryParam> | undefined,
|
|
24
|
+
params: Record<string, unknown>,
|
|
25
|
+
): Record<string, string> {
|
|
26
|
+
const out: Record<string, string> = {}
|
|
27
|
+
for (const [upstream, spec] of Object.entries(query ?? {})) {
|
|
28
|
+
const raw = params[spec.from]
|
|
29
|
+
const v = raw == null || raw === '' ? spec.default : raw
|
|
30
|
+
if (spec.required && (v == null || v === '')) {
|
|
31
|
+
throw new Error(`[api] missing required param "${spec.from}" (→ ${upstream})`)
|
|
32
|
+
}
|
|
33
|
+
if (v == null || v === '') continue
|
|
34
|
+
out[upstream] = String(v)
|
|
35
|
+
}
|
|
36
|
+
return out
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Pull the item list out of a JSON response via a dot-path (e.g. `data.itemList`).
|
|
41
|
+
* The resolved node normalizes to an array: array → itself, single object → `[object]`
|
|
42
|
+
* (so anchor lookups fit the "array of raw items" contract), null/undefined → `[]`.
|
|
43
|
+
*/
|
|
44
|
+
export function pickList(response: unknown, unwrap?: string): unknown[] {
|
|
45
|
+
let node: unknown = response
|
|
46
|
+
if (unwrap) {
|
|
47
|
+
for (const key of unwrap.split('.')) {
|
|
48
|
+
if (node == null || typeof node !== 'object') return []
|
|
49
|
+
node = (node as Record<string, unknown>)[key]
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if (Array.isArray(node)) return node
|
|
53
|
+
if (node && typeof node === 'object') return [node]
|
|
54
|
+
return []
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Execute a declarative binding: resolve the query, GET the endpoint, unwrap the list,
|
|
59
|
+
* and (if declared) map each item through a registered normalizer. `get` returns the
|
|
60
|
+
* parsed JSON response; the adapter injects its HTTP client so this stays unit-testable.
|
|
61
|
+
*/
|
|
62
|
+
export async function runDeclarative(
|
|
63
|
+
binding: DeclApiBinding,
|
|
64
|
+
params: Record<string, unknown>,
|
|
65
|
+
get: (path: string, query: Record<string, string>) => Promise<unknown>,
|
|
66
|
+
normalizers: Record<string, Normalizer> = {},
|
|
67
|
+
): Promise<unknown[]> {
|
|
68
|
+
const query = resolveQuery(binding.query, params)
|
|
69
|
+
const response = await get(binding.endpoint, query)
|
|
70
|
+
const items = pickList(response, binding.unwrap)
|
|
71
|
+
if (!binding.normalize) return items
|
|
72
|
+
const fn = normalizers[binding.normalize]
|
|
73
|
+
if (!fn) throw new Error(`[api] unknown normalize "${binding.normalize}"`)
|
|
74
|
+
return items.map(fn)
|
|
75
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { DouyinAweme, DouyinItem } from '../../../src/adapters/douyin/types.ts'
|
|
2
|
+
|
|
3
|
+
/** Normalize a raw aweme into the bridge item shape makeStreamItem reads (title/link/
|
|
4
|
+
* author/guid/pubDate), carrying the raw aweme under `douyin` for the presenter.
|
|
5
|
+
* Registered as the `douyin` normalizer and used directly by the douyin handlers. */
|
|
6
|
+
export function toDouyinItem(a: DouyinAweme): DouyinItem {
|
|
7
|
+
return {
|
|
8
|
+
guid: a.aweme_id as string,
|
|
9
|
+
// No caption → no title (empty, not a "(无标题)" placeholder). The view drops the title
|
|
10
|
+
// line entirely for untitled posts; a placeholder here would just get rendered.
|
|
11
|
+
title: a.desc || '',
|
|
12
|
+
// search awemes lack share_url — fall back to the canonical /video/<id> url so the
|
|
13
|
+
// card link + downstream proxy/enrich still resolve.
|
|
14
|
+
link: a.share_url || (a.aweme_id ? `https://www.douyin.com/video/${a.aweme_id}` : undefined),
|
|
15
|
+
author: a.author?.nickname,
|
|
16
|
+
author_avatar: a.author?.avatar_thumb?.url_list?.find((u) => !!u),
|
|
17
|
+
pubDate: a.create_time ? new Date(a.create_time * 1000).toISOString() : undefined,
|
|
18
|
+
douyin: a,
|
|
19
|
+
}
|
|
20
|
+
}
|