From e15d066e6de6ebf051599a098c88b2d244b99dad Mon Sep 17 00:00:00 2001 From: Xudong Sun Date: Tue, 25 Nov 2025 16:31:27 +0800 Subject: [PATCH] feat(rss): enhance rss feed with thumbnail and detailed exif info, and fix feed link (#169) --- packages/utils/src/rss.ts | 143 +++++++++++++++++++++++++++++++++++++- rss-spec.md | 55 +++++---------- 2 files changed, 158 insertions(+), 40 deletions(-) diff --git a/packages/utils/src/rss.ts b/packages/utils/src/rss.ts index 91ecc8c5..c03093ad 100644 --- a/packages/utils/src/rss.ts +++ b/packages/utils/src/rss.ts @@ -1,6 +1,9 @@ import type { PhotoManifestItem } from '@afilmory/builder' const GENERATOR_NAME = 'Afilmory Feed Generator' +const EXIF_NAMESPACE = 'https://afilmory.com/rss/exif' +const PROTOCOL_VERSION = '1.1' +const PROTOCOL_ID = 'afilmory-rss-exif' export interface FeedSiteAuthor { name: string @@ -29,7 +32,7 @@ export function generateRSSFeed(photos: readonly PhotoManifestItem[], config: Fe const managingEditor = author && config.author?.url ? `${author} (${config.author.url})` : author return ` - + ${escapeXml(config.title)} ${baseUrl} @@ -38,6 +41,8 @@ export function generateRSSFeed(photos: readonly PhotoManifestItem[], config: Fe ${lastBuildDate} ${GENERATOR_NAME} ${managingEditor ? `${managingEditor}` : ''} + ${PROTOCOL_VERSION} + ${PROTOCOL_ID} ${itemsXml} ` @@ -53,6 +58,20 @@ function createItemXml(photo: PhotoManifestItem, baseUrl: string): string { ? photo.tags.map((tag) => ` ${escapeXml(tag)}`).join('\n') : '' + // Add enclosure for thumbnail if available + // Assuming thumbnail is an image, default to image/jpeg if extension is unknown, but usually it's webp or jpg + let enclosure = '' + if (photo.thumbnailUrl) { + const thumbUrl = photo.thumbnailUrl.startsWith('http') + ? photo.thumbnailUrl + : `${baseUrl}${photo.thumbnailUrl.startsWith('/') ? '' : '/'}${photo.thumbnailUrl}` + // Simple mime type guess + const mimeType = thumbUrl.endsWith('.webp') ? 'image/webp' : thumbUrl.endsWith('.png') ? 'image/png' : 'image/jpeg' + enclosure = ` ` + } + + const exifTags = buildExifTags(photo) + return ` ${title} ${link} @@ -60,9 +79,131 @@ function createItemXml(photo: PhotoManifestItem, baseUrl: string): string { ${pubDate} ${categories} +${enclosure} +${exifTags} ` } +function buildExifTags(photo: PhotoManifestItem): string { + if (!photo.exif) return '' + + const tags: string[] = [] + const { exif } = photo + + // --- Basic Camera Settings --- + if (exif.FNumber) { + tags.push(`f/${exif.FNumber}`) + } + if (exif.ExposureTime) { + // Format shutter speed: if < 1, use fraction, else use seconds + let ss = String(exif.ExposureTime) + if (typeof exif.ExposureTime === 'number') { + if (exif.ExposureTime < 1 && exif.ExposureTime > 0) { + ss = `1/${Math.round(1 / exif.ExposureTime)}s` + } else { + ss = `${exif.ExposureTime}s` + } + } else if (!ss.endsWith('s') && // If it's a string and doesn't end with s, append it? + // Actually exiftool usually gives nice strings or numbers. + // Let's just trust the value but ensure 's' suffix if it looks like a number + !Number.isNaN(Number(ss))) { + ss = `${ss}s` + } + tags.push(`${ss}`) + } + if (exif.ISO) { + tags.push(`${exif.ISO}`) + } + if (exif.ExposureCompensation !== undefined && exif.ExposureCompensation !== null) { + const val = Number(exif.ExposureCompensation) + const sign = val > 0 ? '+' : '' + tags.push(`${sign}${val} EV`) + } + + // --- Lens Parameters --- + if (exif.FocalLength) { + // Ensure 'mm' suffix + const fl = String(exif.FocalLength).replace('mm', '').trim() + tags.push(`${fl}mm`) + } + if (exif.FocalLengthIn35mmFormat) { + const fl35 = String(exif.FocalLengthIn35mmFormat).replace('mm', '').trim() + tags.push(`${fl35}mm`) + } + if (exif.LensModel) { + tags.push(``) + } + if (exif.MaxApertureValue) { + tags.push(`f/${exif.MaxApertureValue}`) + } + + // --- Device Info --- + const camera = [exif.Make, exif.Model].filter(Boolean).join(' ') + if (camera) { + tags.push(``) + } + + // --- Image Attributes --- + if (photo.width) { + tags.push(`${photo.width}`) + } + if (photo.height) { + tags.push(`${photo.height}`) + } + if (photo.dateTaken) { + tags.push(`${photo.dateTaken}`) + } + if (exif.Orientation) { + tags.push(`${exif.Orientation}`) + } + + // --- Location Info --- + // Location info removed as per user request + + // Location name is not directly in standard exif usually, but if we had it in photo info... + // Currently PhotoManifestItem doesn't seem to have a dedicated location name field other than maybe tags or description. + // We'll skip for now unless we find a source. + + // --- Technical Parameters --- + if (exif.WhiteBalance) { + tags.push(`${exif.WhiteBalance}`) + } + if (exif.MeteringMode) { + tags.push(`${exif.MeteringMode}`) + } + // Flash is often a complex object or string in exiftool, simplify if possible or just dump string + if (exif.Flash) { + // Try to map to simple enum if possible, or just use what we have if it's readable + tags.push(`${String(exif.Flash)}`) + } + if (exif.ColorSpace) { + tags.push(`${exif.ColorSpace}`) + } + + // --- Advanced Parameters --- + if (exif.ExposureProgram) { + tags.push(`${exif.ExposureProgram}`) + } + if (exif.SceneCaptureType) { + tags.push(``) + } + + // Try to extract from FujiRecipe if available + if (exif.FujiRecipe) { + if (exif.FujiRecipe.Sharpness) { + tags.push(`${exif.FujiRecipe.Sharpness}`) + } + if (exif.FujiRecipe.Saturation) { + tags.push(`${exif.FujiRecipe.Saturation}`) + } + // Contrast is often "HighlightTone" and "ShadowTone" combined in Fuji, + // or maybe just map one of them? The spec asks for Contrast. + // Let's skip Contrast for FujiRecipe to avoid confusion unless we have a direct mapping. + } + + return tags.map((t) => ` ${t}`).join('\n') +} + function buildDescription(photo: PhotoManifestItem): string { const segments: string[] = [] if (photo.description) { diff --git a/rss-spec.md b/rss-spec.md index c9d8cc77..2f65bff6 100644 --- a/rss-spec.md +++ b/rss-spec.md @@ -131,40 +131,23 @@ #### `` -**描述**: 图像方向 -**格式**: 整数 (1-8) -**示例**: `1` +**描述**: 图像方向 +**格式**: 整数 (1-8) +**示例**: `1` **映射**: EXIF Orientation 字段 -### 位置信息 (location) +### 图片展示 (media) -#### `` +#### `` -**描述**: GPS纬度 -**格式**: 十进制度数 -**示例**: `39.9042` -**映射**: EXIF GPSLatitude 字段 - -#### `` - -**描述**: GPS经度 -**格式**: 十进制度数 -**示例**: `116.4074` -**映射**: EXIF GPSLongitude 字段 - -#### `` - -**描述**: 海拔高度 -**格式**: `{数值}m` -**示例**: `1200m` -**映射**: EXIF GPSAltitude 字段 - -#### `` - -**描述**: 拍摄地点名称 -**格式**: CDATA 包装的字符串 -**示例**: `` -**映射**: 地理编码或用户标注 +**描述**: 缩略图 URL +**位置**: `` 元素内 +**格式**: 标准 RSS enclosure 元素 +**示例**: `` +**说明**: +- `url`: 缩略图的完整 URL,支持相对路径转换为绝对路径 +- `type`: MIME 类型,根据文件扩展名自动判断(webp/png/jpeg) +- `length`: 文件大小(字节),可设为 0 表示未知 ### 技术参数 (technical) @@ -245,12 +228,12 @@ ```xml - + <![CDATA[我的风景摄影画廊]]> https://example.com - + 1.1 afilmory-rss-exif @@ -282,13 +265,7 @@ 50mm 75mm f/1.4 - - - 39.9042 - 116.4074 - 50m - - + Auto Matrix