@jackwener/opencli 1.4.1 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (322) hide show
  1. package/.github/workflows/build-extension.yml +2 -6
  2. package/.github/workflows/ci.yml +21 -1
  3. package/README.md +35 -6
  4. package/README.zh-CN.md +12 -5
  5. package/SKILL.md +2 -0
  6. package/dist/browser/cdp.d.ts +2 -1
  7. package/dist/browser/discover.d.ts +4 -1
  8. package/dist/browser/discover.js +6 -2
  9. package/dist/browser/errors.d.ts +2 -2
  10. package/dist/browser/errors.js +4 -12
  11. package/dist/browser/mcp.d.ts +2 -1
  12. package/dist/build-manifest.d.ts +2 -0
  13. package/dist/build-manifest.js +39 -14
  14. package/dist/build-manifest.test.js +21 -0
  15. package/dist/capabilityRouting.d.ts +2 -0
  16. package/dist/capabilityRouting.js +2 -1
  17. package/dist/cli-manifest.json +1111 -112
  18. package/dist/cli.js +34 -3
  19. package/dist/clis/36kr/article.d.ts +1 -0
  20. package/dist/clis/36kr/article.js +62 -0
  21. package/dist/clis/36kr/hot.d.ts +3 -0
  22. package/dist/clis/36kr/hot.js +80 -0
  23. package/dist/clis/36kr/hot.test.d.ts +1 -0
  24. package/dist/clis/36kr/hot.test.js +15 -0
  25. package/dist/clis/36kr/news.d.ts +1 -0
  26. package/dist/clis/36kr/news.js +51 -0
  27. package/dist/clis/36kr/news.test.d.ts +1 -0
  28. package/dist/clis/36kr/news.test.js +85 -0
  29. package/dist/clis/36kr/search.d.ts +1 -0
  30. package/dist/clis/36kr/search.js +72 -0
  31. package/dist/clis/bilibili/comments.d.ts +5 -0
  32. package/dist/clis/bilibili/comments.js +40 -0
  33. package/dist/clis/bilibili/comments.test.d.ts +1 -0
  34. package/dist/clis/bilibili/comments.test.js +82 -0
  35. package/dist/clis/chatgpt/ask.js +29 -14
  36. package/dist/clis/chatgpt/ax.d.ts +6 -0
  37. package/dist/clis/chatgpt/ax.js +172 -1
  38. package/dist/clis/chatgpt/model.d.ts +1 -0
  39. package/dist/clis/chatgpt/model.js +24 -0
  40. package/dist/clis/chatgpt/send.js +12 -3
  41. package/dist/clis/douban/download.d.ts +1 -0
  42. package/dist/clis/douban/download.js +67 -0
  43. package/dist/clis/douban/download.test.d.ts +1 -0
  44. package/dist/clis/douban/download.test.js +170 -0
  45. package/dist/clis/douban/photos.d.ts +1 -0
  46. package/dist/clis/douban/photos.js +34 -0
  47. package/dist/clis/douban/utils.d.ts +25 -0
  48. package/dist/clis/douban/utils.js +190 -1
  49. package/dist/clis/douban/utils.test.d.ts +1 -0
  50. package/dist/clis/douban/utils.test.js +64 -0
  51. package/dist/clis/imdb/person.d.ts +1 -0
  52. package/dist/clis/imdb/person.js +203 -0
  53. package/dist/clis/imdb/reviews.d.ts +1 -0
  54. package/dist/clis/imdb/reviews.js +88 -0
  55. package/dist/clis/imdb/search.d.ts +1 -0
  56. package/dist/clis/imdb/search.js +161 -0
  57. package/dist/clis/imdb/title.d.ts +1 -0
  58. package/dist/clis/imdb/title.js +93 -0
  59. package/dist/clis/imdb/top.d.ts +1 -0
  60. package/dist/clis/imdb/top.js +53 -0
  61. package/dist/clis/imdb/trending.d.ts +1 -0
  62. package/dist/clis/imdb/trending.js +52 -0
  63. package/dist/clis/imdb/utils.d.ts +46 -0
  64. package/dist/clis/imdb/utils.js +285 -0
  65. package/dist/clis/imdb/utils.test.d.ts +1 -0
  66. package/dist/clis/imdb/utils.test.js +88 -0
  67. package/dist/clis/jd/item.d.ts +4 -0
  68. package/dist/clis/jd/item.js +16 -15
  69. package/dist/clis/jd/item.test.js +16 -1
  70. package/dist/clis/linux-do/categories.yaml +38 -9
  71. package/dist/clis/linux-do/category.d.ts +1 -0
  72. package/dist/clis/linux-do/category.js +36 -0
  73. package/dist/clis/linux-do/feed.d.ts +45 -0
  74. package/dist/clis/linux-do/feed.js +397 -0
  75. package/dist/clis/linux-do/feed.test.d.ts +1 -0
  76. package/dist/clis/linux-do/feed.test.js +118 -0
  77. package/dist/clis/linux-do/hot.d.ts +1 -0
  78. package/dist/clis/linux-do/hot.js +25 -0
  79. package/dist/clis/linux-do/latest.d.ts +1 -0
  80. package/dist/clis/linux-do/latest.js +18 -0
  81. package/dist/clis/linux-do/tags.yaml +41 -0
  82. package/dist/clis/linux-do/topic.yaml +41 -3
  83. package/dist/clis/linux-do/user-posts.yaml +67 -0
  84. package/dist/clis/linux-do/user-topics.yaml +54 -0
  85. package/dist/clis/paperreview/commands.test.d.ts +3 -0
  86. package/dist/clis/paperreview/commands.test.js +243 -0
  87. package/dist/clis/paperreview/feedback.d.ts +1 -0
  88. package/dist/clis/paperreview/feedback.js +52 -0
  89. package/dist/clis/paperreview/review.d.ts +1 -0
  90. package/dist/clis/paperreview/review.js +37 -0
  91. package/dist/clis/paperreview/submit.d.ts +1 -0
  92. package/dist/clis/paperreview/submit.js +85 -0
  93. package/dist/clis/paperreview/utils.d.ts +46 -0
  94. package/dist/clis/paperreview/utils.js +197 -0
  95. package/dist/clis/paperreview/utils.test.d.ts +1 -0
  96. package/dist/clis/paperreview/utils.test.js +49 -0
  97. package/dist/clis/producthunt/browse.d.ts +1 -0
  98. package/dist/clis/producthunt/browse.js +99 -0
  99. package/dist/clis/producthunt/hot.d.ts +1 -0
  100. package/dist/clis/producthunt/hot.js +110 -0
  101. package/dist/clis/producthunt/posts.d.ts +1 -0
  102. package/dist/clis/producthunt/posts.js +28 -0
  103. package/dist/clis/producthunt/today.d.ts +1 -0
  104. package/dist/clis/producthunt/today.js +35 -0
  105. package/dist/clis/producthunt/utils.d.ts +29 -0
  106. package/dist/clis/producthunt/utils.js +99 -0
  107. package/dist/clis/producthunt/utils.test.d.ts +1 -0
  108. package/dist/clis/producthunt/utils.test.js +64 -0
  109. package/dist/clis/twitter/article.js +4 -28
  110. package/dist/clis/twitter/likes.d.ts +24 -0
  111. package/dist/clis/twitter/likes.js +217 -0
  112. package/dist/clis/twitter/likes.test.d.ts +1 -0
  113. package/dist/clis/twitter/likes.test.js +85 -0
  114. package/dist/clis/twitter/profile.js +4 -28
  115. package/dist/clis/twitter/search.js +2 -1
  116. package/dist/clis/twitter/search.test.js +2 -0
  117. package/dist/clis/twitter/shared.d.ts +6 -0
  118. package/dist/clis/twitter/shared.js +35 -0
  119. package/dist/clis/twitter/timeline.js +2 -13
  120. package/dist/clis/weixin/download.d.ts +17 -0
  121. package/dist/clis/weixin/download.js +88 -20
  122. package/dist/clis/weread/book.js +2 -2
  123. package/dist/clis/weread/commands.test.d.ts +3 -0
  124. package/dist/clis/weread/commands.test.js +43 -0
  125. package/dist/clis/weread/highlights.js +2 -2
  126. package/dist/clis/weread/notebooks.js +2 -2
  127. package/dist/clis/weread/notes.js +3 -3
  128. package/dist/clis/weread/shelf.js +2 -2
  129. package/dist/clis/weread/utils.d.ts +4 -4
  130. package/dist/clis/weread/utils.js +32 -14
  131. package/dist/clis/weread/utils.test.js +1 -28
  132. package/dist/clis/xiaohongshu/comments.d.ts +5 -0
  133. package/dist/clis/xiaohongshu/comments.js +74 -0
  134. package/dist/clis/xiaohongshu/comments.test.d.ts +1 -0
  135. package/dist/clis/xiaohongshu/comments.test.js +79 -0
  136. package/dist/clis/xiaohongshu/publish.js +114 -18
  137. package/dist/clis/xiaohongshu/publish.test.d.ts +1 -0
  138. package/dist/clis/xiaohongshu/publish.test.js +119 -0
  139. package/dist/commanderAdapter.d.ts +1 -0
  140. package/dist/commanderAdapter.js +176 -29
  141. package/dist/commanderAdapter.test.d.ts +1 -0
  142. package/dist/commanderAdapter.test.js +62 -0
  143. package/dist/daemon.js +17 -1
  144. package/dist/discovery.js +8 -14
  145. package/dist/doctor.d.ts +1 -0
  146. package/dist/doctor.js +9 -2
  147. package/dist/download/index.js +63 -51
  148. package/dist/download/index.test.js +17 -4
  149. package/dist/errors.d.ts +3 -1
  150. package/dist/errors.js +15 -32
  151. package/dist/execution.d.ts +1 -3
  152. package/dist/execution.js +21 -1
  153. package/dist/hooks.js +2 -0
  154. package/dist/main.js +5 -0
  155. package/dist/output.js +5 -1
  156. package/dist/pipeline/executor.js +3 -4
  157. package/dist/plugin-manifest.d.ts +70 -0
  158. package/dist/plugin-manifest.js +160 -0
  159. package/dist/plugin-manifest.test.d.ts +4 -0
  160. package/dist/plugin-manifest.test.js +179 -0
  161. package/dist/plugin.d.ts +38 -5
  162. package/dist/plugin.js +267 -33
  163. package/dist/plugin.test.js +220 -3
  164. package/dist/registry.d.ts +4 -0
  165. package/dist/registry.js +2 -0
  166. package/dist/runtime-detect.d.ts +21 -0
  167. package/dist/runtime-detect.js +32 -0
  168. package/dist/runtime-detect.test.d.ts +1 -0
  169. package/dist/runtime-detect.test.js +27 -0
  170. package/dist/runtime.js +1 -1
  171. package/dist/serialization.d.ts +2 -0
  172. package/dist/serialization.js +6 -0
  173. package/dist/types.d.ts +1 -0
  174. package/dist/update-check.d.ts +22 -0
  175. package/dist/update-check.js +112 -0
  176. package/dist/weixin-download.test.d.ts +1 -0
  177. package/dist/weixin-download.test.js +30 -0
  178. package/dist/weread-private-api-regression.test.d.ts +1 -0
  179. package/dist/weread-private-api-regression.test.js +122 -0
  180. package/dist/yaml-schema.d.ts +3 -0
  181. package/dist/yaml-schema.js +18 -1
  182. package/docs/.vitepress/config.mts +4 -0
  183. package/docs/adapters/browser/36kr.md +47 -0
  184. package/docs/adapters/browser/douban.md +14 -0
  185. package/docs/adapters/browser/imdb.md +47 -0
  186. package/docs/adapters/browser/jd.md +2 -2
  187. package/docs/adapters/browser/linux-do.md +181 -20
  188. package/docs/adapters/browser/paperreview.md +43 -0
  189. package/docs/adapters/browser/producthunt.md +49 -0
  190. package/docs/adapters/desktop/chatgpt.md +5 -0
  191. package/docs/adapters/index.md +6 -2
  192. package/docs/advanced/download.md +4 -0
  193. package/docs/advanced/rate-limiter-plugin.md +99 -0
  194. package/docs/guide/electron-app-cli.md +200 -0
  195. package/docs/guide/getting-started.md +1 -0
  196. package/docs/guide/plugins.md +87 -0
  197. package/docs/zh/guide/electron-app-cli.md +188 -0
  198. package/docs/zh/guide/getting-started.md +1 -0
  199. package/docs/zh/guide/plugins.md +65 -0
  200. package/extension/package.json +1 -0
  201. package/extension/scripts/package-release.mjs +179 -0
  202. package/extension/src/background.ts +2 -0
  203. package/package.json +4 -1
  204. package/scripts/postinstall.js +10 -0
  205. package/src/browser/cdp.ts +2 -1
  206. package/src/browser/discover.ts +8 -3
  207. package/src/browser/errors.ts +13 -14
  208. package/src/browser/mcp.ts +2 -1
  209. package/src/build-manifest.test.ts +23 -0
  210. package/src/build-manifest.ts +40 -15
  211. package/src/capabilityRouting.ts +2 -1
  212. package/src/cli.ts +35 -3
  213. package/src/clis/36kr/article.ts +69 -0
  214. package/src/clis/36kr/hot.test.ts +19 -0
  215. package/src/clis/36kr/hot.ts +100 -0
  216. package/src/clis/36kr/news.test.ts +90 -0
  217. package/src/clis/36kr/news.ts +54 -0
  218. package/src/clis/36kr/search.ts +78 -0
  219. package/src/clis/bilibili/comments.test.ts +102 -0
  220. package/src/clis/bilibili/comments.ts +44 -0
  221. package/src/clis/chatgpt/ask.ts +28 -14
  222. package/src/clis/chatgpt/ax.ts +180 -1
  223. package/src/clis/chatgpt/model.ts +27 -0
  224. package/src/clis/chatgpt/send.ts +16 -6
  225. package/src/clis/douban/download.test.ts +196 -0
  226. package/src/clis/douban/download.ts +78 -0
  227. package/src/clis/douban/photos.ts +36 -0
  228. package/src/clis/douban/utils.test.ts +97 -0
  229. package/src/clis/douban/utils.ts +232 -1
  230. package/src/clis/imdb/person.ts +232 -0
  231. package/src/clis/imdb/reviews.ts +111 -0
  232. package/src/clis/imdb/search.ts +179 -0
  233. package/src/clis/imdb/title.ts +121 -0
  234. package/src/clis/imdb/top.ts +67 -0
  235. package/src/clis/imdb/trending.ts +66 -0
  236. package/src/clis/imdb/utils.test.ts +117 -0
  237. package/src/clis/imdb/utils.ts +305 -0
  238. package/src/clis/jd/item.test.ts +18 -1
  239. package/src/clis/jd/item.ts +18 -15
  240. package/src/clis/linux-do/categories.yaml +38 -9
  241. package/src/clis/linux-do/category.ts +37 -0
  242. package/src/clis/linux-do/feed.test.ts +132 -0
  243. package/src/clis/linux-do/feed.ts +501 -0
  244. package/src/clis/linux-do/hot.ts +26 -0
  245. package/src/clis/linux-do/latest.ts +19 -0
  246. package/src/clis/linux-do/tags.yaml +41 -0
  247. package/src/clis/linux-do/topic.yaml +41 -3
  248. package/src/clis/linux-do/user-posts.yaml +67 -0
  249. package/src/clis/linux-do/user-topics.yaml +54 -0
  250. package/src/clis/paperreview/commands.test.ts +283 -0
  251. package/src/clis/paperreview/feedback.ts +64 -0
  252. package/src/clis/paperreview/review.ts +47 -0
  253. package/src/clis/paperreview/submit.ts +119 -0
  254. package/src/clis/paperreview/utils.test.ts +68 -0
  255. package/src/clis/paperreview/utils.ts +276 -0
  256. package/src/clis/producthunt/browse.ts +109 -0
  257. package/src/clis/producthunt/hot.ts +127 -0
  258. package/src/clis/producthunt/posts.ts +29 -0
  259. package/src/clis/producthunt/today.ts +37 -0
  260. package/src/clis/producthunt/utils.test.ts +72 -0
  261. package/src/clis/producthunt/utils.ts +122 -0
  262. package/src/clis/twitter/article.ts +5 -28
  263. package/src/clis/twitter/likes.test.ts +91 -0
  264. package/src/clis/twitter/likes.ts +256 -0
  265. package/src/clis/twitter/profile.ts +5 -28
  266. package/src/clis/twitter/search.test.ts +2 -0
  267. package/src/clis/twitter/search.ts +3 -1
  268. package/src/clis/twitter/shared.ts +45 -0
  269. package/src/clis/twitter/timeline.ts +2 -13
  270. package/src/clis/weixin/download.ts +114 -20
  271. package/src/clis/weread/book.ts +2 -2
  272. package/src/clis/weread/commands.test.ts +57 -0
  273. package/src/clis/weread/highlights.ts +2 -2
  274. package/src/clis/weread/notebooks.ts +2 -2
  275. package/src/clis/weread/notes.ts +3 -3
  276. package/src/clis/weread/shelf.ts +2 -2
  277. package/src/clis/weread/utils.test.ts +1 -32
  278. package/src/clis/weread/utils.ts +41 -16
  279. package/src/clis/xiaohongshu/comments.test.ts +96 -0
  280. package/src/clis/xiaohongshu/comments.ts +81 -0
  281. package/src/clis/xiaohongshu/publish.test.ts +137 -0
  282. package/src/clis/xiaohongshu/publish.ts +129 -18
  283. package/src/commanderAdapter.test.ts +78 -0
  284. package/src/commanderAdapter.ts +188 -24
  285. package/src/daemon.ts +19 -1
  286. package/src/discovery.ts +8 -15
  287. package/src/doctor.ts +13 -2
  288. package/src/download/index.test.ts +14 -4
  289. package/src/download/index.ts +67 -55
  290. package/src/errors.ts +25 -66
  291. package/src/execution.ts +28 -3
  292. package/src/hooks.ts +1 -0
  293. package/src/main.ts +6 -0
  294. package/src/output.ts +3 -1
  295. package/src/pipeline/executor.ts +4 -6
  296. package/src/plugin-manifest.test.ts +223 -0
  297. package/src/plugin-manifest.ts +206 -0
  298. package/src/plugin.test.ts +246 -2
  299. package/src/plugin.ts +338 -36
  300. package/src/registry.ts +6 -1
  301. package/src/runtime-detect.test.ts +30 -0
  302. package/src/runtime-detect.ts +36 -0
  303. package/src/runtime.ts +1 -1
  304. package/src/serialization.ts +4 -0
  305. package/src/types.ts +1 -0
  306. package/src/update-check.ts +114 -0
  307. package/src/weixin-download.test.ts +64 -0
  308. package/src/weread-private-api-regression.test.ts +150 -0
  309. package/src/yaml-schema.ts +20 -0
  310. package/tests/e2e/browser-auth.test.ts +13 -9
  311. package/tests/e2e/browser-public-extended.test.ts +1 -1
  312. package/tests/e2e/browser-public.test.ts +62 -4
  313. package/tests/e2e/helpers.ts +2 -1
  314. package/tests/e2e/public-commands.test.ts +37 -3
  315. package/tests/smoke/api-health.test.ts +1 -1
  316. package/vitest.config.ts +10 -0
  317. package/dist/clis/linux-do/category.yaml +0 -51
  318. package/dist/clis/linux-do/hot.yaml +0 -50
  319. package/dist/clis/linux-do/latest.yaml +0 -40
  320. package/src/clis/linux-do/category.yaml +0 -51
  321. package/src/clis/linux-do/hot.yaml +0 -50
  322. package/src/clis/linux-do/latest.yaml +0 -40
@@ -0,0 +1,196 @@
1
+ import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import path from 'node:path';
3
+ import type { CliCommand } from '../../registry.js';
4
+ import { getRegistry } from '../../registry.js';
5
+ import type { IPage } from '../../types.js';
6
+
7
+ const { mockHttpDownload, mockLoadDoubanSubjectPhotos, mockMkdirSync } = vi.hoisted(() => ({
8
+ mockHttpDownload: vi.fn(),
9
+ mockLoadDoubanSubjectPhotos: vi.fn(),
10
+ mockMkdirSync: vi.fn(),
11
+ }));
12
+
13
+ vi.mock('../../download/index.js', () => ({
14
+ httpDownload: mockHttpDownload,
15
+ sanitizeFilename: vi.fn((value: string) => value.replace(/\s+/g, '_')),
16
+ }));
17
+
18
+ vi.mock('./utils.js', async () => {
19
+ const actual = await vi.importActual<typeof import('./utils.js')>('./utils.js');
20
+ return {
21
+ ...actual,
22
+ loadDoubanSubjectPhotos: mockLoadDoubanSubjectPhotos,
23
+ };
24
+ });
25
+
26
+ vi.mock('../../download/progress.js', () => ({
27
+ formatBytes: vi.fn((size: number) => `${size} B`),
28
+ }));
29
+
30
+ vi.mock('node:fs', () => ({
31
+ mkdirSync: mockMkdirSync,
32
+ }));
33
+
34
+ await import('./download.js');
35
+
36
+ let cmd: CliCommand;
37
+
38
+ beforeAll(() => {
39
+ cmd = getRegistry().get('douban/download')!;
40
+ expect(cmd?.func).toBeTypeOf('function');
41
+ });
42
+
43
+ function toPosixPath(value: string): string {
44
+ return value.replaceAll(path.sep, '/');
45
+ }
46
+
47
+ describe('douban download', () => {
48
+ beforeEach(() => {
49
+ mockHttpDownload.mockReset();
50
+ mockLoadDoubanSubjectPhotos.mockReset();
51
+ mockMkdirSync.mockReset();
52
+ });
53
+
54
+ it('downloads douban poster images and merges metadata into the result', async () => {
55
+ const page = {} as IPage;
56
+ mockLoadDoubanSubjectPhotos.mockResolvedValue({
57
+ subjectId: '30382501',
58
+ subjectTitle: 'The Wandering Earth 2',
59
+ type: 'Rb',
60
+ photos: [
61
+ {
62
+ index: 1,
63
+ photoId: '2913450214',
64
+ title: 'Main poster',
65
+ imageUrl: 'https://img1.doubanio.com/view/photo/l/public/p2913450214.webp',
66
+ thumbUrl: 'https://img1.doubanio.com/view/photo/m/public/p2913450214.webp',
67
+ detailUrl: 'https://movie.douban.com/photos/photo/2913450214/',
68
+ page: 1,
69
+ },
70
+ {
71
+ index: 2,
72
+ photoId: '2913450215',
73
+ title: 'Character poster',
74
+ imageUrl: 'https://img1.doubanio.com/view/photo/l/public/p2913450215.jpg',
75
+ thumbUrl: 'https://img1.doubanio.com/view/photo/m/public/p2913450215.jpg',
76
+ detailUrl: 'https://movie.douban.com/photos/photo/2913450215/',
77
+ page: 1,
78
+ },
79
+ ],
80
+ });
81
+
82
+ mockHttpDownload
83
+ .mockResolvedValueOnce({ success: true, size: 1200 })
84
+ .mockResolvedValueOnce({ success: true, size: 980 });
85
+
86
+ const result = await cmd.func!(page, {
87
+ id: '30382501',
88
+ type: 'Rb',
89
+ limit: 20,
90
+ output: '/tmp/douban-test',
91
+ }) as Array<Record<string, unknown>>;
92
+
93
+ expect(mockLoadDoubanSubjectPhotos).toHaveBeenCalledWith(page, '30382501', {
94
+ type: 'Rb',
95
+ limit: 20,
96
+ });
97
+ expect(mockMkdirSync).toHaveBeenCalledTimes(1);
98
+ expect(toPosixPath(mockMkdirSync.mock.calls[0][0])).toBe('/tmp/douban-test/30382501');
99
+ expect(mockMkdirSync.mock.calls[0][1]).toEqual({ recursive: true });
100
+ expect(mockHttpDownload).toHaveBeenCalledTimes(2);
101
+ expect(mockHttpDownload.mock.calls[0]?.[0]).toBe('https://img1.doubanio.com/view/photo/l/public/p2913450214.webp');
102
+ expect(toPosixPath(mockHttpDownload.mock.calls[0]?.[1])).toBe('/tmp/douban-test/30382501/30382501_001_2913450214_Main_poster.webp');
103
+ expect(mockHttpDownload.mock.calls[0]?.[2]).toEqual(expect.objectContaining({
104
+ headers: { Referer: 'https://movie.douban.com/photos/photo/2913450214/' },
105
+ timeout: 60000,
106
+ }));
107
+ expect(mockHttpDownload.mock.calls[1]?.[0]).toBe('https://img1.doubanio.com/view/photo/l/public/p2913450215.jpg');
108
+ expect(toPosixPath(mockHttpDownload.mock.calls[1]?.[1])).toBe('/tmp/douban-test/30382501/30382501_002_2913450215_Character_poster.jpg');
109
+ expect(mockHttpDownload.mock.calls[1]?.[2]).toEqual(expect.objectContaining({
110
+ headers: { Referer: 'https://movie.douban.com/photos/photo/2913450215/' },
111
+ timeout: 60000,
112
+ }));
113
+
114
+ expect(result).toEqual([
115
+ {
116
+ index: 1,
117
+ title: 'Main poster',
118
+ photo_id: '2913450214',
119
+ image_url: 'https://img1.doubanio.com/view/photo/l/public/p2913450214.webp',
120
+ detail_url: 'https://movie.douban.com/photos/photo/2913450214/',
121
+ status: 'success',
122
+ size: '1200 B',
123
+ },
124
+ {
125
+ index: 2,
126
+ title: 'Character poster',
127
+ photo_id: '2913450215',
128
+ image_url: 'https://img1.doubanio.com/view/photo/l/public/p2913450215.jpg',
129
+ detail_url: 'https://movie.douban.com/photos/photo/2913450215/',
130
+ status: 'success',
131
+ size: '980 B',
132
+ },
133
+ ]);
134
+ });
135
+
136
+ it('downloads only the requested photo when photo-id is provided', async () => {
137
+ const page = {} as IPage;
138
+ mockLoadDoubanSubjectPhotos.mockResolvedValue({
139
+ subjectId: '30382501',
140
+ subjectTitle: 'The Wandering Earth 2',
141
+ type: 'Rb',
142
+ photos: [
143
+ {
144
+ index: 2,
145
+ photoId: '2913450215',
146
+ title: 'Character poster',
147
+ imageUrl: 'https://img1.doubanio.com/view/photo/l/public/p2913450215.jpg',
148
+ thumbUrl: 'https://img1.doubanio.com/view/photo/m/public/p2913450215.jpg',
149
+ detailUrl: 'https://movie.douban.com/photos/photo/2913450215/',
150
+ page: 1,
151
+ },
152
+ ],
153
+ });
154
+
155
+ mockHttpDownload.mockResolvedValueOnce({ success: true, size: 980 });
156
+
157
+ const result = await cmd.func!(page, {
158
+ id: '30382501',
159
+ type: 'Rb',
160
+ 'photo-id': '2913450215',
161
+ output: '/tmp/douban-test',
162
+ }) as Array<Record<string, unknown>>;
163
+
164
+ expect(mockLoadDoubanSubjectPhotos).toHaveBeenCalledWith(page, '30382501', {
165
+ type: 'Rb',
166
+ targetPhotoId: '2913450215',
167
+ });
168
+ expect(mockHttpDownload).toHaveBeenCalledTimes(1);
169
+ expect(mockHttpDownload.mock.calls[0]?.[0]).toBe('https://img1.doubanio.com/view/photo/l/public/p2913450215.jpg');
170
+ expect(toPosixPath(mockHttpDownload.mock.calls[0]?.[1])).toBe('/tmp/douban-test/30382501/30382501_002_2913450215_Character_poster.jpg');
171
+ expect(mockHttpDownload.mock.calls[0]?.[2]).toEqual(expect.objectContaining({
172
+ headers: { Referer: 'https://movie.douban.com/photos/photo/2913450215/' },
173
+ timeout: 60000,
174
+ }));
175
+
176
+ expect(result).toEqual([
177
+ {
178
+ index: 2,
179
+ title: 'Character poster',
180
+ photo_id: '2913450215',
181
+ image_url: 'https://img1.doubanio.com/view/photo/l/public/p2913450215.jpg',
182
+ detail_url: 'https://movie.douban.com/photos/photo/2913450215/',
183
+ status: 'success',
184
+ size: '980 B',
185
+ },
186
+ ]);
187
+ });
188
+
189
+ it('rejects invalid subject ids before attempting browser work', async () => {
190
+ await expect(
191
+ cmd.func!({} as IPage, { id: 'movie-30382501' }),
192
+ ).rejects.toThrow('Invalid Douban subject ID');
193
+
194
+ expect(mockHttpDownload).not.toHaveBeenCalled();
195
+ });
196
+ });
@@ -0,0 +1,78 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+ import { formatBytes } from '../../download/progress.js';
4
+ import { httpDownload, sanitizeFilename } from '../../download/index.js';
5
+ import { EmptyResultError } from '../../errors.js';
6
+ import { cli, Strategy } from '../../registry.js';
7
+ import type { DoubanSubjectPhoto, LoadDoubanSubjectPhotosOptions } from './utils.js';
8
+ import { getDoubanPhotoExtension, loadDoubanSubjectPhotos, normalizeDoubanSubjectId } from './utils.js';
9
+
10
+ function buildDoubanPhotoFilename(subjectId: string, photo: DoubanSubjectPhoto): string {
11
+ const index = String(photo.index).padStart(3, '0');
12
+ const suffix = sanitizeFilename(photo.title || photo.photoId || 'photo', 80) || 'photo';
13
+ return `${subjectId}_${index}_${photo.photoId || 'photo'}_${suffix}${getDoubanPhotoExtension(photo.imageUrl)}`;
14
+ }
15
+
16
+ cli({
17
+ site: 'douban',
18
+ name: 'download',
19
+ description: '下载电影海报/剧照图片',
20
+ domain: 'movie.douban.com',
21
+ strategy: Strategy.COOKIE,
22
+ args: [
23
+ { name: 'id', positional: true, required: true, help: '电影 subject ID' },
24
+ { name: 'type', default: 'Rb', help: '豆瓣 photos 的 type 参数,默认 Rb(海报)' },
25
+ { name: 'limit', type: 'int', default: 120, help: '最多下载多少张图片' },
26
+ { name: 'photo-id', help: '只下载指定 photo_id 的图片' },
27
+ { name: 'output', default: './douban-downloads', help: '输出目录' },
28
+ ],
29
+ columns: ['index', 'title', 'status', 'size'],
30
+ func: async (page, kwargs) => {
31
+ const subjectId = normalizeDoubanSubjectId(String(kwargs.id || ''));
32
+ const output = String(kwargs.output || './douban-downloads');
33
+ const requestedPhotoId = String(kwargs['photo-id'] || '').trim();
34
+ const loadOptions: LoadDoubanSubjectPhotosOptions = {
35
+ type: String(kwargs.type || 'Rb'),
36
+ };
37
+ if (requestedPhotoId) loadOptions.targetPhotoId = requestedPhotoId;
38
+ else loadOptions.limit = Number(kwargs.limit) || 120;
39
+
40
+ const data = await loadDoubanSubjectPhotos(page, subjectId, loadOptions);
41
+
42
+ const photos = requestedPhotoId
43
+ ? data.photos.filter((photo) => photo.photoId === requestedPhotoId)
44
+ : data.photos;
45
+
46
+ if (requestedPhotoId && !photos.length) {
47
+ throw new EmptyResultError(
48
+ 'douban download',
49
+ `Photo ID ${requestedPhotoId} was not found under subject ${subjectId}. Try "douban photos ${subjectId} -f json" first.`,
50
+ );
51
+ }
52
+
53
+ const outputDir = path.join(output, subjectId);
54
+ fs.mkdirSync(outputDir, { recursive: true });
55
+
56
+ const results: Array<Record<string, unknown>> = [];
57
+ for (const photo of photos) {
58
+ const filename = buildDoubanPhotoFilename(subjectId, photo);
59
+ const destPath = path.join(outputDir, filename);
60
+ const result = await httpDownload(photo.imageUrl, destPath, {
61
+ headers: { Referer: photo.detailUrl || `https://movie.douban.com/subject/${subjectId}/photos?type=${encodeURIComponent(String(kwargs.type || 'Rb'))}` },
62
+ timeout: 60000,
63
+ });
64
+
65
+ results.push({
66
+ index: photo.index,
67
+ title: photo.title,
68
+ photo_id: photo.photoId,
69
+ image_url: photo.imageUrl,
70
+ detail_url: photo.detailUrl,
71
+ status: result.success ? 'success' : 'failed',
72
+ size: result.success ? formatBytes(result.size) : (result.error || 'unknown error'),
73
+ });
74
+ }
75
+
76
+ return results;
77
+ },
78
+ });
@@ -0,0 +1,36 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+ import { loadDoubanSubjectPhotos, normalizeDoubanSubjectId } from './utils.js';
3
+
4
+ cli({
5
+ site: 'douban',
6
+ name: 'photos',
7
+ description: '获取电影海报/剧照图片列表',
8
+ domain: 'movie.douban.com',
9
+ strategy: Strategy.COOKIE,
10
+ args: [
11
+ { name: 'id', positional: true, required: true, help: '电影 subject ID' },
12
+ { name: 'type', default: 'Rb', help: '豆瓣 photos 的 type 参数,默认 Rb(海报)' },
13
+ { name: 'limit', type: 'int', default: 120, help: '最多返回多少张图片' },
14
+ ],
15
+ columns: ['index', 'title', 'image_url', 'detail_url'],
16
+ func: async (page, kwargs) => {
17
+ const subjectId = normalizeDoubanSubjectId(String(kwargs.id || ''));
18
+ const data = await loadDoubanSubjectPhotos(page, subjectId, {
19
+ type: String(kwargs.type || 'Rb'),
20
+ limit: Number(kwargs.limit) || 120,
21
+ });
22
+
23
+ return data.photos.map((photo) => ({
24
+ subject_id: data.subjectId,
25
+ subject_title: data.subjectTitle,
26
+ type: data.type,
27
+ index: photo.index,
28
+ photo_id: photo.photoId,
29
+ title: photo.title,
30
+ image_url: photo.imageUrl,
31
+ thumb_url: photo.thumbUrl,
32
+ detail_url: photo.detailUrl,
33
+ page: photo.page,
34
+ }));
35
+ },
36
+ });
@@ -0,0 +1,97 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import type { IPage } from '../../types.js';
3
+ import {
4
+ getDoubanPhotoExtension,
5
+ loadDoubanSubjectPhotos,
6
+ normalizeDoubanSubjectId,
7
+ promoteDoubanPhotoUrl,
8
+ resolveDoubanPhotoAssetUrl,
9
+ } from './utils.js';
10
+
11
+ describe('douban utils', () => {
12
+ it('normalizes valid subject ids', () => {
13
+ expect(normalizeDoubanSubjectId(' 30382501 ')).toBe('30382501');
14
+ });
15
+
16
+ it('rejects invalid subject ids', () => {
17
+ expect(() => normalizeDoubanSubjectId('tt30382501')).toThrow('Invalid Douban subject ID');
18
+ });
19
+
20
+ it('promotes thumbnail urls to large photo urls', () => {
21
+ expect(
22
+ promoteDoubanPhotoUrl('https://img1.doubanio.com/view/photo/m/public/p2913450214.webp'),
23
+ ).toBe('https://img1.doubanio.com/view/photo/l/public/p2913450214.webp');
24
+
25
+ expect(
26
+ promoteDoubanPhotoUrl('https://img9.doubanio.com/view/photo/s_ratio_poster/public/p2578474613.jpg'),
27
+ ).toBe('https://img9.doubanio.com/view/photo/l/public/p2578474613.jpg');
28
+ });
29
+
30
+ it('rejects non-http photo urls during promotion', () => {
31
+ expect(promoteDoubanPhotoUrl('data:image/gif;base64,abc')).toBe('');
32
+ });
33
+
34
+ it('prefers lazy-loaded photo urls over data placeholders', () => {
35
+ expect(
36
+ resolveDoubanPhotoAssetUrl([
37
+ '',
38
+ 'https://img1.doubanio.com/view/photo/m/public/p2913450214.webp',
39
+ 'data:image/gif;base64,abc',
40
+ ], 'https://movie.douban.com/subject/30382501/photos?type=Rb'),
41
+ ).toBe('https://img1.doubanio.com/view/photo/m/public/p2913450214.webp');
42
+ });
43
+
44
+ it('drops unsupported non-http photo urls when no real image url exists', () => {
45
+ expect(
46
+ resolveDoubanPhotoAssetUrl(
47
+ ['data:image/gif;base64,abc', 'blob:https://movie.douban.com/example'],
48
+ 'https://movie.douban.com/subject/30382501/photos?type=Rb',
49
+ ),
50
+ ).toBe('');
51
+ });
52
+
53
+ it('removes the default photo cap when scanning for an exact photo id', async () => {
54
+ const evaluate = vi.fn()
55
+ .mockResolvedValueOnce({ blocked: false, title: 'Some Movie', href: 'https://movie.douban.com/subject/30382501/photos?type=Rb' })
56
+ .mockResolvedValueOnce({
57
+ subjectId: '30382501',
58
+ subjectTitle: 'The Wandering Earth 2',
59
+ type: 'Rb',
60
+ photos: [
61
+ {
62
+ index: 731,
63
+ photoId: '2913450215',
64
+ title: 'Character poster',
65
+ imageUrl: 'https://img1.doubanio.com/view/photo/l/public/p2913450215.jpg',
66
+ thumbUrl: 'https://img1.doubanio.com/view/photo/m/public/p2913450215.jpg',
67
+ detailUrl: 'https://movie.douban.com/photos/photo/2913450215/',
68
+ page: 25,
69
+ },
70
+ ],
71
+ });
72
+ const page = {
73
+ goto: vi.fn().mockResolvedValue(undefined),
74
+ wait: vi.fn().mockResolvedValue(undefined),
75
+ evaluate,
76
+ } as unknown as IPage;
77
+
78
+ await loadDoubanSubjectPhotos(page, '30382501', {
79
+ type: 'Rb',
80
+ targetPhotoId: '2913450215',
81
+ });
82
+
83
+ const scanScript = evaluate.mock.calls[1]?.[0];
84
+ expect(scanScript).toContain('const targetPhotoId = "2913450215";');
85
+ expect(scanScript).toContain(`const limit = ${Number.MAX_SAFE_INTEGER};`);
86
+ expect(scanScript).toContain('for (let pageIndex = 0; photos.length < limit; pageIndex += 1)');
87
+ });
88
+
89
+ it('keeps image extensions when download urls contain query params', () => {
90
+ expect(
91
+ getDoubanPhotoExtension('https://img1.doubanio.com/view/photo/l/public/p2913450214.webp?foo=1'),
92
+ ).toBe('.webp');
93
+ expect(
94
+ getDoubanPhotoExtension('https://img1.doubanio.com/view/photo/l/public/p2913450214.jpeg'),
95
+ ).toBe('.jpeg');
96
+ });
97
+ });
@@ -2,13 +2,20 @@
2
2
  * Douban adapter utilities.
3
3
  */
4
4
 
5
- import { CliError } from '../../errors.js';
5
+ import { ArgumentError, CliError, EmptyResultError } from '../../errors.js';
6
6
  import type { IPage } from '../../types.js';
7
7
 
8
+ const DOUBAN_PHOTO_PAGE_SIZE = 30;
9
+ const MAX_DOUBAN_PHOTOS = 500;
10
+
8
11
  function clampLimit(limit: number): number {
9
12
  return Math.max(1, Math.min(limit || 20, 50));
10
13
  }
11
14
 
15
+ function clampPhotoLimit(limit: number): number {
16
+ return Math.max(1, Math.min(limit || 120, MAX_DOUBAN_PHOTOS));
17
+ }
18
+
12
19
  async function ensureDoubanReady(page: IPage): Promise<void> {
13
20
  const state = await page.evaluate(`
14
21
  (() => {
@@ -27,6 +34,230 @@ async function ensureDoubanReady(page: IPage): Promise<void> {
27
34
  }
28
35
  }
29
36
 
37
+ export interface DoubanSubjectPhoto {
38
+ index: number;
39
+ photoId: string;
40
+ title: string;
41
+ imageUrl: string;
42
+ thumbUrl: string;
43
+ detailUrl: string;
44
+ page: number;
45
+ }
46
+
47
+ export interface DoubanSubjectPhotosResult {
48
+ subjectId: string;
49
+ subjectTitle: string;
50
+ type: string;
51
+ photos: DoubanSubjectPhoto[];
52
+ }
53
+
54
+ export interface LoadDoubanSubjectPhotosOptions {
55
+ type?: string;
56
+ limit?: number;
57
+ targetPhotoId?: string;
58
+ }
59
+
60
+ export function normalizeDoubanSubjectId(subjectId: string): string {
61
+ const normalized = String(subjectId || '').trim();
62
+ if (!/^\d+$/.test(normalized)) {
63
+ throw new ArgumentError(`Invalid Douban subject ID: ${subjectId}`);
64
+ }
65
+ return normalized;
66
+ }
67
+
68
+ export function promoteDoubanPhotoUrl(url: string, size: 's' | 'm' | 'l' = 'l'): string {
69
+ const normalized = String(url || '').trim();
70
+ if (!normalized) return '';
71
+ if (/^[a-z]+:/i.test(normalized) && !/^https?:/i.test(normalized)) return '';
72
+ return normalized.replace(/\/view\/photo\/[^/]+\/public\//, `/view/photo/${size}/public/`);
73
+ }
74
+
75
+ export function resolveDoubanPhotoAssetUrl(
76
+ candidates: Array<string | null | undefined>,
77
+ baseUrl = '',
78
+ ): string {
79
+ for (const candidate of candidates) {
80
+ const normalized = String(candidate || '').trim();
81
+ if (!normalized) continue;
82
+
83
+ let resolved = normalized;
84
+ try {
85
+ resolved = baseUrl
86
+ ? new URL(normalized, baseUrl).toString()
87
+ : new URL(normalized).toString();
88
+ } catch {
89
+ resolved = normalized;
90
+ }
91
+
92
+ if (/^https?:\/\//i.test(resolved)) {
93
+ return resolved;
94
+ }
95
+ }
96
+
97
+ return '';
98
+ }
99
+
100
+ export function getDoubanPhotoExtension(url: string): string {
101
+ const normalized = String(url || '').trim();
102
+ if (!normalized) return '.jpg';
103
+
104
+ try {
105
+ const ext = new URL(normalized).pathname.match(/\.(jpe?g|png|gif|webp|avif|bmp)$/i)?.[0];
106
+ return ext || '.jpg';
107
+ } catch {
108
+ const ext = normalized.match(/\.(jpe?g|png|gif|webp|avif|bmp)(?:$|[?#])/i)?.[0];
109
+ return ext ? ext.replace(/[?#].*$/, '') : '.jpg';
110
+ }
111
+ }
112
+
113
+ export async function loadDoubanSubjectPhotos(
114
+ page: IPage,
115
+ subjectId: string,
116
+ options: LoadDoubanSubjectPhotosOptions = {},
117
+ ): Promise<DoubanSubjectPhotosResult> {
118
+ const normalizedId = normalizeDoubanSubjectId(subjectId);
119
+ const type = String(options.type || 'Rb').trim() || 'Rb';
120
+ const targetPhotoId = String(options.targetPhotoId || '').trim();
121
+ const safeLimit = targetPhotoId ? Number.MAX_SAFE_INTEGER : clampPhotoLimit(Number(options.limit) || 120);
122
+ const resolvePhotoAssetUrlSource = resolveDoubanPhotoAssetUrl.toString();
123
+
124
+ const galleryUrl = `https://movie.douban.com/subject/${normalizedId}/photos?type=${encodeURIComponent(type)}`;
125
+ await page.goto(galleryUrl);
126
+ await page.wait(2);
127
+ await ensureDoubanReady(page);
128
+
129
+ const data = await page.evaluate(`
130
+ (async () => {
131
+ const subjectId = ${JSON.stringify(normalizedId)};
132
+ const type = ${JSON.stringify(type)};
133
+ const limit = ${safeLimit};
134
+ const targetPhotoId = ${JSON.stringify(targetPhotoId)};
135
+ const pageSize = ${DOUBAN_PHOTO_PAGE_SIZE};
136
+ const resolveDoubanPhotoAssetUrl = ${resolvePhotoAssetUrlSource};
137
+
138
+ const normalize = (value) => (value || '').replace(/\\s+/g, ' ').trim();
139
+ const toAbsoluteUrl = (value) => {
140
+ if (!value) return '';
141
+ try {
142
+ return new URL(value, location.origin).toString();
143
+ } catch {
144
+ return value;
145
+ }
146
+ };
147
+ const promotePhotoUrl = (value) => {
148
+ const absolute = toAbsoluteUrl(value);
149
+ if (!absolute) return '';
150
+ if (/^[a-z]+:/i.test(absolute) && !/^https?:/i.test(absolute)) return '';
151
+ return absolute.replace(/\\/view\\/photo\\/[^/]+\\/public\\//, '/view/photo/l/public/');
152
+ };
153
+ const buildPageUrl = (start) => {
154
+ const url = new URL(location.href);
155
+ url.searchParams.set('type', type);
156
+ if (start > 0) url.searchParams.set('start', String(start));
157
+ else url.searchParams.delete('start');
158
+ return url.toString();
159
+ };
160
+ const getTitle = (doc) => {
161
+ const raw = normalize(doc.querySelector('#content h1')?.textContent)
162
+ || normalize(doc.querySelector('title')?.textContent);
163
+ return raw.replace(/\\s*\\(豆瓣\\)\\s*$/, '');
164
+ };
165
+ const extractPhotos = (doc, pageNumber) => {
166
+ const nodes = Array.from(doc.querySelectorAll('.poster-col3 li, .poster-col3l li, .article li'));
167
+ const rows = [];
168
+ for (const node of nodes) {
169
+ const link = node.querySelector('a[href*="/photos/photo/"]');
170
+ const img = node.querySelector('img');
171
+ if (!link || !img) continue;
172
+
173
+ const detailUrl = toAbsoluteUrl(link.getAttribute('href') || '');
174
+ const photoId = detailUrl.match(/\\/photo\\/(\\d+)/)?.[1] || '';
175
+ const thumbUrl = resolveDoubanPhotoAssetUrl([
176
+ img.getAttribute('data-origin'),
177
+ img.getAttribute('data-src'),
178
+ img.getAttribute('src'),
179
+ ], location.href);
180
+ const imageUrl = promotePhotoUrl(thumbUrl);
181
+ const title = normalize(link.getAttribute('title'))
182
+ || normalize(img.getAttribute('alt'))
183
+ || (photoId ? 'photo_' + photoId : 'photo_' + String(rows.length + 1));
184
+
185
+ if (!detailUrl || !thumbUrl || !imageUrl) continue;
186
+
187
+ rows.push({
188
+ photoId,
189
+ title,
190
+ imageUrl,
191
+ thumbUrl,
192
+ detailUrl,
193
+ page: pageNumber,
194
+ });
195
+ }
196
+ return rows;
197
+ };
198
+
199
+ const subjectTitle = getTitle(document);
200
+ const seen = new Set();
201
+ const photos = [];
202
+
203
+ for (let pageIndex = 0; photos.length < limit; pageIndex += 1) {
204
+ let doc = document;
205
+ if (pageIndex > 0) {
206
+ const response = await fetch(buildPageUrl(pageIndex * pageSize), { credentials: 'include' });
207
+ if (!response.ok) break;
208
+ const html = await response.text();
209
+ doc = new DOMParser().parseFromString(html, 'text/html');
210
+ }
211
+
212
+ const pagePhotos = extractPhotos(doc, pageIndex + 1);
213
+ if (!pagePhotos.length) break;
214
+
215
+ let appended = 0;
216
+ let foundTarget = false;
217
+ for (const photo of pagePhotos) {
218
+ const key = photo.photoId || photo.detailUrl || photo.imageUrl;
219
+ if (seen.has(key)) continue;
220
+ seen.add(key);
221
+ photos.push({
222
+ index: photos.length + 1,
223
+ ...photo,
224
+ });
225
+ appended += 1;
226
+ if (targetPhotoId && photo.photoId === targetPhotoId) {
227
+ foundTarget = true;
228
+ break;
229
+ }
230
+ if (photos.length >= limit) break;
231
+ }
232
+
233
+ if (foundTarget || pagePhotos.length < pageSize || appended === 0) break;
234
+ }
235
+
236
+ return {
237
+ subjectId,
238
+ subjectTitle,
239
+ type,
240
+ photos,
241
+ };
242
+ })()
243
+ `);
244
+
245
+ const photos = Array.isArray(data?.photos) ? data.photos : [];
246
+ if (!photos.length) {
247
+ throw new EmptyResultError(
248
+ 'douban photos',
249
+ 'No photos found. Try a different subject ID or a different --type value such as Rb.',
250
+ );
251
+ }
252
+
253
+ return {
254
+ subjectId: normalizedId,
255
+ subjectTitle: String(data?.subjectTitle || '').trim(),
256
+ type,
257
+ photos,
258
+ };
259
+ }
260
+
30
261
  export async function loadDoubanBookHot(page: IPage, limit: number): Promise<any[]> {
31
262
  const safeLimit = clampLimit(limit);
32
263
  await page.goto('https://book.douban.com/chart');