commit c023e530e1c6e1e2c2bd1063f7f35c90b9482f87 Author: HCha Date: Mon Apr 7 15:42:28 2025 +0800 init diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..e98d070 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,26 @@ +node_modules +npm-debug.log + +logs +*.log + +cache +*.jpg +*.json +!package.json +!package-lock.json +!config/*.json + +.git +.github +.gitignore +.vscode +.env +.env.* + +README.md +LICENSE +*.md +Dockerfile +docker-compose.yml +.dockerignore \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ef79118 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +logs/ +cache/ +package-lock.json \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0ac279c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,18 @@ +FROM node:18-alpine + +WORKDIR /app + +COPY package*.json ./ + +RUN npm ci --only=production + +COPY . . + +RUN mkdir -p /app/cache /app/logs && \ + chown -R node:node /app + +USER node + +EXPOSE 3000 + +CMD ["node", "app.js"] \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..fec49a5 --- /dev/null +++ b/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2025 HCha. All rights reserved. + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..573ca23 --- /dev/null +++ b/README.md @@ -0,0 +1,81 @@ +# BingWallpaperAPI + +This project is a simple API service that caches the daily Bing wallpaper and provides it for use in other applications. It fetches the wallpaper metadata from the Bing API, downloads the image, and saves it locally. + +## Features + +- Fetches the daily Bing wallpaper. +- Caches the wallpaper image and metadata. +- Provides an API endpoint to access the wallpaper. +- Automatically updates the wallpaper on a daily basis. + +## Installation + +1. Clone the repository: + + ```bash + git clone https://github.com/HChaZZY/BingWallpaperAPI.git + cd BingWallpaperAPI + ``` +2. Install the dependencies: + + ```bash + npm install + ``` + +3. **Docker** (Optional) + + - Using Docker Compose: + + ```bash + docker-compose up -d + ``` + + - Using Docker: + + ```bash + docker build -t bing-wallpaper-api . + docker run -d -p 3000:3000 bing-wallpaper-api + ``` + +## Usage + +1. Configure the settings in `config/default.json` if needed: + - Server port and host (`server.port` and `server.host`) + - Bing API URL (`bing.apiUrl` and `bing.baseUrl`) + - Cache paths (`cache.path` and `cache.metadataPath`) + - Update schedule (`schedule.checkInterval`, using cron format) + - Retry settings (`retry.maxAttempts` and `retry.delayMs`) + +2. Start the server: + + ```bash + npm start + ``` + + After successful startup, the console will display the following information: + ``` + Listening 0.0.0.0:3000 + http://localhost:3000 for Wallpaper + ``` + +3. Access wallpaper and health check: + - Wallpaper image: `http://localhost:3000/images` + - Health check: `http://localhost:3000/health` + +## System Requirements + +- Node.js >= 14.0.0 + +## Main Dependencies + +- Express: Web server framework +- Axios: HTTP client +- node-cron: Scheduled task scheduling +- Winston: Log recording + +## License + +MIT License + +© 2025 HCha. All rights reserved. diff --git a/app.js b/app.js new file mode 100644 index 0000000..103a0d3 --- /dev/null +++ b/app.js @@ -0,0 +1,96 @@ +const express = require('express'); +const cron = require('node-cron'); +const fs = require('fs'); +const path = require('path'); +const bingService = require('./src/services/bing.service'); +const config = require('./config/default.json'); +const { createLogger } = require('./src/utils/logger'); + +const logger = createLogger('app'); + +const app = express(); + +app.use(express.static(path.join(__dirname, 'public'))); +const PORT = process.env.PORT || config.server.port; +const HOST = config.server.host; + +const wallpaperCachePath = path.resolve(config.cache.path); +const metadataCachePath = path.resolve(config.cache.metadataPath); + +const cacheDir = path.dirname(wallpaperCachePath); +if (!fs.existsSync(cacheDir)) { + fs.mkdirSync(cacheDir, { recursive: true }); + logger.info(`Created cache directory: ${cacheDir}`); +} + +async function updateWallpaper() { + try { + logger.info('Start checking for wallpaper update...'); + const updated = await bingService.checkAndUpdateWallpaper(); + + if (updated) { + logger.info('Wallpaper updated. Saving metadata...'); + } else { + logger.info('Wallpaper does not need updating.'); + } + } catch (error) { + logger.error(`Wallpaper update failed: ${error.message}`); + } +} + +(async () => { + try { + if (!fs.existsSync(wallpaperCachePath)) { + logger.info('No cached wallpaper found. Downloading...'); + await updateWallpaper(); + } else { + logger.info(`Found cached wallpaper: ${wallpaperCachePath}`); + } + } catch (error) { + logger.error(`Initial wallpaper load failed: ${error.message}`); + } +})(); + +cron.schedule(config.schedule.checkInterval, async () => { + logger.info('Executing scheduled wallpaper update.'); + await updateWallpaper(); +}); + +app.get('/images', (req, res) => { + try { + if (fs.existsSync(wallpaperCachePath)) { + res.setHeader('Content-Type', 'image/jpeg'); + res.setHeader('Cache-Control', 'public, max-age=3600'); + + return res.sendFile(wallpaperCachePath); + } else { + logger.warn('No wallpaper found in cache. Returning 404.'); + return res.status(404).send('No wallpaper found.'); + } + } catch (error) { + logger.error(`Wallpaper request failed: ${error.message}`); + return res.status(500).send('Internal Server Error'); + } +}); + +app.get('/health', (req, res) => { + return res.json({ status: 'OK', version: '1.0.0' }); +}); + +app.listen(PORT, HOST, () => { + logger.info(`Listening ${HOST}:${PORT}`); + logger.info(`http://localhost:${PORT} for Wallpaper`); +}); + +process.on('SIGTERM', () => { + logger.info('SIGTERM, shutting down gracefully'); + process.exit(0); +}); + +process.on('uncaughtException', (error) => { + logger.error(`Uncaught Exception: ${error.message}`, { stack: error.stack }); +}); + +process.on('unhandledRejection', (reason, promise) => { + logger.error('UnhandledPromiseRejection: ', { reason }); +}); \ No newline at end of file diff --git a/config/default.json b/config/default.json new file mode 100644 index 0000000..69bed2f --- /dev/null +++ b/config/default.json @@ -0,0 +1,21 @@ +{ + "server": { + "port": 3000, + "host": "0.0.0.0" + }, + "bing": { + "apiUrl": "https://cn.bing.com/HPImageArchive.aspx?format=js&n=1&idx=0&mkt=zh-CN", + "baseUrl": "https://global.bing.com" + }, + "cache": { + "path": "./cache/bing-wallpaper.jpg", + "metadataPath": "./cache/metadata.json" + }, + "schedule": { + "checkInterval": "0 * * * *" + }, + "retry": { + "maxAttempts": 3, + "delayMs": 5000 + } +} \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..5295f5b --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,30 @@ +version: '3.8' + +services: + app: + build: + context: . + dockerfile: Dockerfile + image: bing-wallpaper-api:1.0.0 + container_name: bing-wallpaper-api + restart: unless-stopped + ports: + - "3000:3000" + volumes: + - bing_cache:/app/cache + - bing_logs:/app/logs + environment: + - NODE_ENV=production + - PORT=3000 + healthcheck: + test: ["CMD", "wget", "--spider", "http://localhost:3000/health"] + interval: 1m + timeout: 10s + retries: 3 + start_period: 30s + +volumes: + bing_cache: + name: bing_wallpaper_cache + bing_logs: + name: bing_wallpaper_logs \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..23399b8 --- /dev/null +++ b/package.json @@ -0,0 +1,31 @@ +{ + "name": "bing-wallpaper-api", + "version": "1.0.0", + "description": "Cache Bing daily wallpaper and provide API service", + "main": "app.js", + "scripts": { + "start": "node app.js", + "dev": "nodemon app.js", + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [ + "bing", + "wallpaper", + "api" + ], + "author": "", + "license": "MIT", + "dependencies": { + "axios": "^1.3.4", + "bing-wallpaper-api": "file:", + "express": "^4.18.2", + "node-cron": "^3.0.2", + "winston": "^3.8.2" + }, + "devDependencies": { + "nodemon": "^2.0.22" + }, + "engines": { + "node": ">=14.0.0" + } +} diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..b9dea95 --- /dev/null +++ b/public/index.html @@ -0,0 +1,27 @@ + + + + + + Bing Wallpaper + + + + Bing Wallpaper + + \ No newline at end of file diff --git a/src/services/bing.service.js b/src/services/bing.service.js new file mode 100644 index 0000000..5e426a8 --- /dev/null +++ b/src/services/bing.service.js @@ -0,0 +1,158 @@ +const axios = require('axios'); +const fs = require('fs'); +const path = require('path'); +const { promisify } = require('util'); +const config = require('../../config/default.json'); +const writeFile = promisify(fs.writeFile); +const { createLogger } = require('../utils/logger'); + +const logger = createLogger('bing-service'); + +class BingService { + constructor() { + this.apiUrl = config.bing.apiUrl; + this.baseUrl = config.bing.baseUrl; + this.cachePath = config.cache.path; + this.metadataPath = config.cache.metadataPath; + this.maxRetryAttempts = config.retry.maxAttempts; + this.retryDelay = config.retry.delayMs; + } + + async getMetadata() { + let retryCount = 0; + + while (retryCount < this.maxRetryAttempts) { + try { + logger.info('Acquiring Bing wallpaper metadata...'); + const response = await axios.get(this.apiUrl); + + if (response.status !== 200 || !response.data || !response.data.images) { + throw new Error('Invalid response from Bing API'); + } + + logger.info('Bing wallpaper metadata acquired successfully.'); + return response.data; + } catch (error) { + retryCount++; + logger.error(`Failed to acquire Bing wallpaper metadata (Attempt ${retryCount}/${this.maxRetryAttempts}): ${error.message}`); + + if (retryCount < this.maxRetryAttempts) { + logger.info(`Retry after ${this.retryDelay / 1000} seconds...`); + await new Promise(resolve => setTimeout(resolve, this.retryDelay)); + } else { + throw new Error(`Failed to acquire Bing wallpaper metadata, max retries reached: ${error.message}`); + } + } + } + } + + generateWallpaperUrl(metadata) { + try { + if (!metadata || !metadata.images || !metadata.images[0]) { + throw new Error('Invalid metadata provided.'); + } + + const image = metadata.images[0]; + const urlBase = image.urlbase; + + const uhdUrl = `${urlBase}_UHD.jpg`; + const fullUhdUrl = `${this.baseUrl}${uhdUrl}`; + + logger.info(`Generated wallpaper URL: ${fullUhdUrl}`); + + return { + url: fullUhdUrl, + startDate: image.startdate, + title: image.title, + copyright: image.copyright + }; + } catch (error) { + logger.error(`GenerateWallpaperUrl error: ${error.message}`); + throw error; + } + } + + async downloadWallpaper(url) { + try { + logger.info(`Downloading wallpaper from: ${url}`); + + const cacheDir = path.dirname(this.cachePath); + if (!fs.existsSync(cacheDir)) { + fs.mkdirSync(cacheDir, { recursive: true }); + } + + const response = await axios({ + method: 'get', + url: url, + responseType: 'arraybuffer' + }); + + if (response.status !== 200) { + throw new Error(`Download failed with status code: ${response.status}`); + } + + await writeFile(this.cachePath, response.data); + logger.info(`Wallpaper saved to: ${this.cachePath}`); + + return true; + } catch (error) { + logger.error(`Download wallpaper error: ${error.message}`); + throw error; + } + } + + async saveMetadata(metadata) { + try { + const cacheDir = path.dirname(this.metadataPath); + if (!fs.existsSync(cacheDir)) { + fs.mkdirSync(cacheDir, { recursive: true }); + } + + await writeFile(this.metadataPath, JSON.stringify(metadata, null, 2)); + logger.info(`Metadata saved to: ${this.metadataPath}`); + + return true; + } catch (error) { + logger.error(`Metadata save error: ${error.message}`); + throw error; + } + } + + async checkAndUpdateWallpaper() { + try { + const metadata = await this.getMetadata(); + const wallpaperInfo = this.generateWallpaperUrl(metadata); + + let needUpdate = true; + + if (fs.existsSync(this.metadataPath)) { + try { + const oldMetadata = JSON.parse(fs.readFileSync(this.metadataPath, 'utf8')); + if (oldMetadata.images && + oldMetadata.images[0] && + oldMetadata.images[0].startdate === metadata.images[0].startdate) { + needUpdate = false; + logger.info('Wallpaper is up to date. No update needed.'); + } + } catch (error) { + logger.warn(`Failed to read old metadata, forced update: ${error.message}`); + } + } + + if (needUpdate) { + await this.downloadWallpaper(wallpaperInfo.url); + await this.saveMetadata(metadata); + + logger.info('Wallpaper updated successfully.'); + return true; + } + + return false; + } catch (error) { + logger.error(`Failed to check and update wallpaper: ${error.message}`); + throw error; + } + } +} + +module.exports = new BingService(); \ No newline at end of file diff --git a/src/utils/logger.js b/src/utils/logger.js new file mode 100644 index 0000000..73f56d7 --- /dev/null +++ b/src/utils/logger.js @@ -0,0 +1,52 @@ +const winston = require('winston'); +const path = require('path'); +const fs = require('fs'); + +const logDir = path.join(process.cwd(), 'logs'); +if (!fs.existsSync(logDir)) { + fs.mkdirSync(logDir, { recursive: true }); +} + +function createLogger(module) { + return winston.createLogger({ + level: process.env.NODE_ENV === 'production' ? 'info' : 'debug', + format: winston.format.combine( + winston.format.timestamp({ + format: 'YYYY-MM-DD HH:mm:ss' + }), + winston.format.errors({ stack: true }), + winston.format.printf(info => { + const { timestamp, level, message, ...rest } = info; + const moduleStr = module ? `[${module}]` : ''; + const restString = Object.keys(rest).length > 0 ? + `\n${JSON.stringify(rest, null, 2)}` : ''; + + return `${timestamp} ${level.toUpperCase()} ${moduleStr}: ${message}${restString}`; + }) + ), + defaultMeta: { service: 'bing-wallpaper-api' }, + transports: [ + new winston.transports.Console({ + format: winston.format.combine( + winston.format.colorize(), + winston.format.simple() + ) + }), + new winston.transports.File({ + filename: path.join(logDir, 'error.log'), + level: 'error', + maxsize: 5242880, // 5MB + maxFiles: 5 + }), + new winston.transports.File({ + filename: path.join(logDir, 'combined.log'), + maxsize: 5242880, // 5MB + maxFiles: 2 + }) + ] + }); +} + +module.exports = { + createLogger +}; \ No newline at end of file