├── .gitignore
├── assets
├── icon.png
└── favicon.ico
├── .dockerignore
├── Dockerfile
├── package.json
├── README.md
├── .github
└── workflows
│ └── docker-image.yml
└── app.js
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
--------------------------------------------------------------------------------
/assets/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/shanyan-wcx/Abs-Ximalaya/HEAD/assets/icon.png
--------------------------------------------------------------------------------
/assets/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/shanyan-wcx/Abs-Ximalaya/HEAD/assets/favicon.ico
--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------
1 | # 依赖
2 | node_modules
3 | npm-debug.log
4 |
5 | # Git 相关文件无关紧要
6 | .git
7 | .gitignore
8 |
9 | # 编辑器/系统临时文件
10 | .DS_Store
11 | Thumbs.db
12 | *.swp
13 | *.tmp
14 |
15 | # 本地构建产物或缓存
16 | dist
17 | build
18 | .cache
19 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM alpine:latest
2 |
3 | ENV TZ=Asia/Shanghai
4 | ENV PORT=7814
5 | ENV NODE_ENV=production
6 |
7 | RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo "$TZ" > /etc/timezone && apk add --no-cache nodejs npm
8 |
9 | WORKDIR /app
10 |
11 | COPY package.json ./
12 | RUN npm install --production
13 |
14 | COPY . .
15 |
16 | EXPOSE ${PORT}
17 |
18 | CMD ["npm", "start"]
19 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "abs-ximalaya",
3 | "version": "1.1.0",
4 | "main": "app.js",
5 | "scripts": {
6 | "test": "echo \"Error: no test specified\" && exit 1",
7 | "start": "node app.js"
8 | },
9 | "keywords": [],
10 | "author": "",
11 | "license": "ISC",
12 | "description": "",
13 | "dependencies": {
14 | "axios": "^1.12.2",
15 | "cheerio": "^1.1.2",
16 | "express": "^5.1.0"
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | 
2 |
3 | # Abs-Ximalaya
4 |
5 | Audiobookshelf的喜马拉雅元数据提供程序。
6 |
7 | GitHub存储库:https://github.com/shanyan-wcx/Abs-Ximalaya
8 |
9 | ## 使用方法
10 |
11 | ```bash
12 | docker pull shanyanwcx/abs-ximalaya:latest
13 | ```
14 |
15 | 启动容器后在Audiobookshelf->设置->项目元数据管理->自定义元数据提供者中点击增加,输入名称和`http://IP:PORT`,再点击增加即可。
16 |
17 | 容器默认端口为`7814`,注意URL最后不能带`/`。
18 |
19 | 支持使用`PORT`环境变量自定义端口,使用`TZ`环境变量自定义时区。
20 |
21 | 
22 |
23 | 
24 |
25 | ## 感谢
26 | 喜马拉雅API来自https://github.com/qilishidai/ximalaya-API
27 |
--------------------------------------------------------------------------------
/.github/workflows/docker-image.yml:
--------------------------------------------------------------------------------
1 | # This is a basic workflow to help you get started with Actions
2 | name: build docker image
3 | # Controls when the action will run.
4 | on:
5 | release:
6 | types: [published]
7 |
8 | env:
9 | TZ: Asia/Shanghai
10 |
11 | jobs:
12 | buildx:
13 | runs-on: ubuntu-latest
14 | steps:
15 | - name: Checkout
16 | uses: actions/checkout@v2
17 |
18 | - name: Get current date
19 | id: date
20 | run: echo "::set-output name=today::$(date +'%Y-%m-%d_%H-%M')"
21 |
22 | - name: Set up QEMU
23 | uses: docker/setup-qemu-action@v1
24 |
25 | - name: Set up Docker Buildx
26 | id: buildx
27 | uses: docker/setup-buildx-action@v1
28 |
29 | - name: Available platforms
30 | run: echo ${{ steps.buildx.outputs.platforms }}
31 |
32 | - name: Login to DockerHub
33 | uses: docker/login-action@v1
34 | with:
35 | username: ${{ secrets.DOCKER_USERNAME }}
36 | password: ${{ secrets.DOCKER_PASSWORD }}
37 |
38 | - name: Build and push
39 | uses: docker/build-push-action@v2
40 | with:
41 | context: .
42 | file: ./Dockerfile
43 | # 所需要的体系结构,可以在 Available platforms 步骤中获取所有的可用架构
44 | platforms: linux/amd64,linux/arm64/v8
45 | # 镜像推送时间
46 | push: ${{ github.event_name != 'pull_request' }}
47 | # 给清单打上多个标签
48 | tags: |
49 | shanyanwcx/abs-ximalaya:${{ steps.date.outputs.today }}
50 | shanyanwcx/abs-ximalaya:latest
51 |
--------------------------------------------------------------------------------
/app.js:
--------------------------------------------------------------------------------
1 | const express = require('express');
2 | const axios = require('axios');
3 | const cheerio = require('cheerio');
4 |
5 | const app = express();
6 | const port = process.env.PORT || 7814;
7 |
8 | // 移除购买须知及其后的所有内容
9 | function removePurchaseNotes(htmlContent) {
10 | const $ = cheerio.load(htmlContent);
11 |
12 | let found = false;
13 | $('p').each(function() {
14 | const $p = $(this);
15 | if (!found && /购买须知/.test($p.text())) {
16 | found = true;
17 | }
18 | if (found) {
19 | $p.remove();
20 | }
21 | });
22 |
23 | return $.html();
24 | }
25 |
26 | // 移除结尾多余的
标签
27 | function removeTrailingBr(html) {
28 | const $ = cheerio.load(html);
29 |
30 | // 删除 body 末尾所有连续的
标签(无论是否被包裹)
31 | $('body').find('br').each((_, el) => {
32 | // 如果这个
之后没有非空内容,就删掉
33 | const next = $(el).nextAll().text().trim();
34 | if (!next && $(el).parent().nextAll().text().trim() === '') {
35 | $(el).remove();
36 | }
37 | });
38 |
39 | // 清空仅包含
的标签(例如
)
40 | $('body').find('*').each((_, el) => {
41 | const html = $(el).html()?.trim();
42 | if (html && /^(\s*
\s*)+$/.test(html)) {
43 | $(el).empty();
44 | }
45 | });
46 |
47 | return $.html();
48 | }
49 |
50 | // 使用 JSON 解析中间件
51 | app.use(express.urlencoded({ extended: true }));
52 | app.use(express.json({ encoding: 'utf-8' }));
53 |
54 | // 设置ICON
55 | app.use('/favicon.ico', express.static('assets/favicon.ico'));
56 |
57 | // 首页
58 | app.get('/', (req, res) => {
59 | res.status(200).send("欢迎使用Abs-Ximalaya!
这是一个Audiobookshelf的喜马拉雅元数据提供程序。");
60 | });
61 |
62 | // === 用于获取详细介绍的请求头和Cookie,从你的Python代码转换而来 ===
63 | const detailApiHeaders = {
64 | 'User-Agent': 'Mozilla/5.0 (Linux; Android 9; SM-S9110 Build/PQ3A.190605.09291615; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/92.0.4515.131 Mobile Safari/537.36 iting(main)/9.3.96/android_1 xmly(main)/9.3.96/android_1 kdtUnion_iting/9.3.96',
65 | 'Accept': 'application/json, text/plain, */*',
66 | 'x-requested-with': 'XMLHttpRequest',
67 | 'sec-fetch-site': 'same-origin',
68 | 'sec-fetch-mode': 'cors',
69 | 'sec-fetch-dest': 'empty',
70 | 'referer': 'https://mobile.ximalaya.com/',
71 | 'accept-language': 'zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7',
72 | };
73 |
74 | const detailApiCookies = '1&_device=android&28b5647f-40d9-3cb6-802a-54905eccc23d&9.3.96; 1&_token=575426552&C29CC6B0140C8E529835C3060AD1FE97FBF87FFBF4DB5BFB15C60DECE3899A36EDA3462173EE229Mbf90403ACAFF0C4_; channel=and-f5; impl=com.ximalaya.ting.android; osversion=28; fp=009517657x2222322v64v050210000k120211200200000001103611000040; device_model=SM-S9110; XUM=CAAn8P8v; c-oper=%E4%B8%AD%E5%9B%BD%E7%A7%BB%E5%8A%A8; net-mode=WIFI; res=1600%2C900; AID=Yjg2YWIyZTRmNzYyN2FjNA==; manufacturer=samsung; umid=ai0fc70f150ccc444005b5c665d7ee7861; xm_grade=0; specialModeStatus=0; yzChannel=and-f5; _xmLog=h5&9550461b-17b4-4dcc-ab09-8609fcda6c02&2.4.24; xm-page-viewid=album-detail-intro';
75 |
76 | // 搜索书籍 - 使用 async/await 进行异步处理
77 | app.get('/search', async (req, res, next) => {
78 | try {
79 | const { query, author } = req.query;
80 | let kw = query || author;
81 |
82 | console.log(`开始搜索 - 标题:${query};作者:${author}`);
83 |
84 | if (!kw) {
85 | console.log("搜索关键词为空");
86 | return res.status(200).json({ matches: [] });
87 | }
88 |
89 | // 1. 发起初始搜索请求
90 | const searchUrl = `https://www.ximalaya.com/revision/search?core=album&kw=${encodeURI(kw)}&page=1&spellchecker=true&rows=20&condition=relation&device=web`; // 减少行数以提高性能,例如20
91 | const searchResponse = await axios.get(searchUrl);
92 |
93 | if (searchResponse.data.ret !== 200) {
94 | console.log("搜索API失败:", searchResponse.data.msg);
95 | return res.status(searchResponse.data.ret).send(searchResponse.data.msg);
96 | }
97 |
98 | const searchResults = searchResponse.data.data.result.response.docs;
99 |
100 | if (!searchResults || searchResults.length === 0) {
101 | console.log("什么也没找到~");
102 | return res.status(200).json({ matches: [] });
103 | }
104 | console.log(`初步搜索成功,找到 ${searchResults.length} 条结果。`);
105 |
106 | // 2. 并行获取每个结果的详细介绍
107 | const bookPromises = searchResults.map(async (element) => {
108 | let richDescription = element.intro; // 默认使用旧的简介
109 |
110 | try {
111 | const detailUrl = 'https://mobile.ximalaya.com/mobile-album/album/plant/detail';
112 | const detailResponse = await axios.get(detailUrl, {
113 | params: {
114 | albumId: element.id, // 使用搜索结果的 id
115 | identity: 'podcast',
116 | supportWebp: 'true',
117 | },
118 | headers: {
119 | 'Cookie': detailApiCookies
120 | }
121 | });
122 |
123 | const richIntroHtml = detailResponse.data?.data?.intro?.richIntro;
124 | if (richIntroHtml) {
125 | richDescription = removePurchaseNotes(richIntroHtml);
126 | richDescription = removeTrailingBr(richDescription);
127 | console.log(`成功获取 Album ID: ${element.id} 的详细介绍`);
128 | }
129 | } catch (error) {
130 | console.error(`获取 Album ID: ${element.id} 的详细介绍失败:`, error.message);
131 | // 如果获取失败,我们已经设置了默认的 element.intro,所以不需要额外操作
132 | }
133 |
134 | // 3. 构建最终的书籍信息对象
135 | const tags = 'tags' in element ? element.tags.split(',') : [];
136 | const cover_path = ("http:" + element.cover_path).replace(/!op_type=3&columns=290&rows=290&magick=png/g, "");
137 | const date = new Date(element.created_at);
138 | const year = date.getFullYear();
139 | let author_ = author;
140 | if (!((element.intro && element.intro.includes(author)) || (element.custom_title && element.custom_title.includes(author)) || (element.title && element.title.includes(author)))) {
141 | author_ = undefined; // 如果作者不匹配,则不设置
142 | }
143 |
144 | return {
145 | title: element.title,
146 | subtitle: element.custom_title,
147 | author: author_,
148 | narrator: element.nickname,
149 | publisher: "喜马拉雅",
150 | publishedYear: year,
151 | description: richDescription, // 使用获取到的新简介
152 | cover: cover_path,
153 | isbn: undefined,
154 | asin: undefined,
155 | genres: [element.category_title],
156 | tags: tags,
157 | series: undefined,
158 | language: element.category_title === "外语" ? "外语" : "中文",
159 | duration: undefined
160 | };
161 | });
162 |
163 | // 等待所有详细信息的请求完成
164 | const books = await Promise.all(bookPromises);
165 |
166 | console.log(`搜索完成:共返回 ${books.length} 条处理后的结果`);
167 | res.status(200).json({ matches: books });
168 |
169 | } catch (error) {
170 | // 捕获所有异步过程中的错误
171 | next(error);
172 | }
173 | });
174 |
175 | // 错误处理
176 | app.use((err, req, res, next) => {
177 | console.error(err.stack);
178 | res.status(500).send('Error - 发生了一些错误!');
179 | });
180 |
181 | // 未定义路由处理
182 | app.use((req, res, next) => {
183 | res.status(404).send("404 - 页面不存在。");
184 | });
185 |
186 | app.listen(port, '::', () => {
187 | console.log(`Server is running at http://localhost:${port}`);
188 | });
--------------------------------------------------------------------------------