├── .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 | ![icon](https://raw.githubusercontent.com/shanyan-wcx/Abs-Ximalaya/mian/assets/icon.png) 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 | ![屏幕截图 2024-05-07 234206](https://github.com/shanyan-wcx/Abs-Ximalaya/assets/58252651/46f7e2a0-979b-4efd-adf5-3c3efcad4ca1) 22 | 23 | ![屏幕截图 2024-05-07 234503](https://github.com/shanyan-wcx/Abs-Ximalaya/assets/58252651/265cfb3e-459e-4a90-89a2-5134c31f2c39) 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 | }); --------------------------------------------------------------------------------