mirror of
https://github.com/Afilmory/afilmory
synced 2026-02-01 22:48:17 +00:00
feat(rss): enhance rss feed with thumbnail and detailed exif info, and fix feed link (#169)
This commit is contained in:
@@ -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 `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0">
|
||||
<rss version="2.0" xmlns:exif="${EXIF_NAMESPACE}">
|
||||
<channel>
|
||||
<title>${escapeXml(config.title)}</title>
|
||||
<link>${baseUrl}</link>
|
||||
@@ -38,6 +41,8 @@ export function generateRSSFeed(photos: readonly PhotoManifestItem[], config: Fe
|
||||
<lastBuildDate>${lastBuildDate}</lastBuildDate>
|
||||
<generator>${GENERATOR_NAME}</generator>
|
||||
${managingEditor ? `<managingEditor>${managingEditor}</managingEditor>` : ''}
|
||||
<exif:version>${PROTOCOL_VERSION}</exif:version>
|
||||
<exif:protocol>${PROTOCOL_ID}</exif:protocol>
|
||||
${itemsXml}
|
||||
</channel>
|
||||
</rss>`
|
||||
@@ -53,6 +58,20 @@ function createItemXml(photo: PhotoManifestItem, baseUrl: string): string {
|
||||
? photo.tags.map((tag) => ` <category>${escapeXml(tag)}</category>`).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 = ` <enclosure url="${escapeXml(thumbUrl)}" type="${mimeType}" length="0" />`
|
||||
}
|
||||
|
||||
const exifTags = buildExifTags(photo)
|
||||
|
||||
return ` <item>
|
||||
<title>${title}</title>
|
||||
<link>${link}</link>
|
||||
@@ -60,9 +79,131 @@ function createItemXml(photo: PhotoManifestItem, baseUrl: string): string {
|
||||
<pubDate>${pubDate}</pubDate>
|
||||
<description><![CDATA[${summary}]]></description>
|
||||
${categories}
|
||||
${enclosure}
|
||||
${exifTags}
|
||||
</item>`
|
||||
}
|
||||
|
||||
function buildExifTags(photo: PhotoManifestItem): string {
|
||||
if (!photo.exif) return ''
|
||||
|
||||
const tags: string[] = []
|
||||
const { exif } = photo
|
||||
|
||||
// --- Basic Camera Settings ---
|
||||
if (exif.FNumber) {
|
||||
tags.push(`<exif:aperture>f/${exif.FNumber}</exif:aperture>`)
|
||||
}
|
||||
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(`<exif:shutterSpeed>${ss}</exif:shutterSpeed>`)
|
||||
}
|
||||
if (exif.ISO) {
|
||||
tags.push(`<exif:iso>${exif.ISO}</exif:iso>`)
|
||||
}
|
||||
if (exif.ExposureCompensation !== undefined && exif.ExposureCompensation !== null) {
|
||||
const val = Number(exif.ExposureCompensation)
|
||||
const sign = val > 0 ? '+' : ''
|
||||
tags.push(`<exif:exposureCompensation>${sign}${val} EV</exif:exposureCompensation>`)
|
||||
}
|
||||
|
||||
// --- Lens Parameters ---
|
||||
if (exif.FocalLength) {
|
||||
// Ensure 'mm' suffix
|
||||
const fl = String(exif.FocalLength).replace('mm', '').trim()
|
||||
tags.push(`<exif:focalLength>${fl}mm</exif:focalLength>`)
|
||||
}
|
||||
if (exif.FocalLengthIn35mmFormat) {
|
||||
const fl35 = String(exif.FocalLengthIn35mmFormat).replace('mm', '').trim()
|
||||
tags.push(`<exif:focalLength35mm>${fl35}mm</exif:focalLength35mm>`)
|
||||
}
|
||||
if (exif.LensModel) {
|
||||
tags.push(`<exif:lens><![CDATA[${exif.LensModel}]]></exif:lens>`)
|
||||
}
|
||||
if (exif.MaxApertureValue) {
|
||||
tags.push(`<exif:maxAperture>f/${exif.MaxApertureValue}</exif:maxAperture>`)
|
||||
}
|
||||
|
||||
// --- Device Info ---
|
||||
const camera = [exif.Make, exif.Model].filter(Boolean).join(' ')
|
||||
if (camera) {
|
||||
tags.push(`<exif:camera><![CDATA[${camera}]]></exif:camera>`)
|
||||
}
|
||||
|
||||
// --- Image Attributes ---
|
||||
if (photo.width) {
|
||||
tags.push(`<exif:imageWidth>${photo.width}</exif:imageWidth>`)
|
||||
}
|
||||
if (photo.height) {
|
||||
tags.push(`<exif:imageHeight>${photo.height}</exif:imageHeight>`)
|
||||
}
|
||||
if (photo.dateTaken) {
|
||||
tags.push(`<exif:dateTaken>${photo.dateTaken}</exif:dateTaken>`)
|
||||
}
|
||||
if (exif.Orientation) {
|
||||
tags.push(`<exif:orientation>${exif.Orientation}</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 <exif:location> for now unless we find a source.
|
||||
|
||||
// --- Technical Parameters ---
|
||||
if (exif.WhiteBalance) {
|
||||
tags.push(`<exif:whiteBalance>${exif.WhiteBalance}</exif:whiteBalance>`)
|
||||
}
|
||||
if (exif.MeteringMode) {
|
||||
tags.push(`<exif:meteringMode>${exif.MeteringMode}</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(`<exif:flashMode>${String(exif.Flash)}</exif:flashMode>`)
|
||||
}
|
||||
if (exif.ColorSpace) {
|
||||
tags.push(`<exif:colorSpace>${exif.ColorSpace}</exif:colorSpace>`)
|
||||
}
|
||||
|
||||
// --- Advanced Parameters ---
|
||||
if (exif.ExposureProgram) {
|
||||
tags.push(`<exif:exposureProgram>${exif.ExposureProgram}</exif:exposureProgram>`)
|
||||
}
|
||||
if (exif.SceneCaptureType) {
|
||||
tags.push(`<exif:sceneMode><![CDATA[${exif.SceneCaptureType}]]></exif:sceneMode>`)
|
||||
}
|
||||
|
||||
// Try to extract from FujiRecipe if available
|
||||
if (exif.FujiRecipe) {
|
||||
if (exif.FujiRecipe.Sharpness) {
|
||||
tags.push(`<exif:sharpness>${exif.FujiRecipe.Sharpness}</exif:sharpness>`)
|
||||
}
|
||||
if (exif.FujiRecipe.Saturation) {
|
||||
tags.push(`<exif:saturation>${exif.FujiRecipe.Saturation}</exif: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) {
|
||||
|
||||
55
rss-spec.md
55
rss-spec.md
@@ -131,40 +131,23 @@
|
||||
|
||||
#### `<exif:orientation>`
|
||||
|
||||
**描述**: 图像方向
|
||||
**格式**: 整数 (1-8)
|
||||
**示例**: `<exif:orientation>1</exif:orientation>`
|
||||
**描述**: 图像方向
|
||||
**格式**: 整数 (1-8)
|
||||
**示例**: `<exif:orientation>1</exif:orientation>`
|
||||
**映射**: EXIF Orientation 字段
|
||||
|
||||
### 位置信息 (location)
|
||||
### 图片展示 (media)
|
||||
|
||||
#### `<exif:gpsLatitude>`
|
||||
#### `<enclosure>`
|
||||
|
||||
**描述**: GPS纬度
|
||||
**格式**: 十进制度数
|
||||
**示例**: `<exif:gpsLatitude>39.9042</exif:gpsLatitude>`
|
||||
**映射**: EXIF GPSLatitude 字段
|
||||
|
||||
#### `<exif:gpsLongitude>`
|
||||
|
||||
**描述**: GPS经度
|
||||
**格式**: 十进制度数
|
||||
**示例**: `<exif:gpsLongitude>116.4074</exif:gpsLongitude>`
|
||||
**映射**: EXIF GPSLongitude 字段
|
||||
|
||||
#### `<exif:altitude>`
|
||||
|
||||
**描述**: 海拔高度
|
||||
**格式**: `{数值}m`
|
||||
**示例**: `<exif:altitude>1200m</exif:altitude>`
|
||||
**映射**: EXIF GPSAltitude 字段
|
||||
|
||||
#### `<exif:location>`
|
||||
|
||||
**描述**: 拍摄地点名称
|
||||
**格式**: CDATA 包装的字符串
|
||||
**示例**: `<exif:location><![CDATA[北京天安门广场]]></exif:location>`
|
||||
**映射**: 地理编码或用户标注
|
||||
**描述**: 缩略图 URL
|
||||
**位置**: `<item>` 元素内
|
||||
**格式**: 标准 RSS enclosure 元素
|
||||
**示例**: `<enclosure url="https://example.com/thumbnails/photo.webp" type="image/webp" length="0" />`
|
||||
**说明**:
|
||||
- `url`: 缩略图的完整 URL,支持相对路径转换为绝对路径
|
||||
- `type`: MIME 类型,根据文件扩展名自动判断(webp/png/jpeg)
|
||||
- `length`: 文件大小(字节),可设为 0 表示未知
|
||||
|
||||
### 技术参数 (technical)
|
||||
|
||||
@@ -245,12 +228,12 @@
|
||||
|
||||
```xml
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" >
|
||||
<rss version="2.0" xmlns:exif="https://afilmory.com/rss/exif">
|
||||
<channel>
|
||||
<title><![CDATA[我的风景摄影画廊]]></title>
|
||||
<link>https://example.com</link>
|
||||
<description><![CDATA[分享我的风景摄影作品]]></description>
|
||||
|
||||
|
||||
<!-- 协议元数据 -->
|
||||
<exif:version>1.1</exif:version>
|
||||
<exif:protocol>afilmory-rss-exif</exif:protocol>
|
||||
@@ -282,13 +265,7 @@
|
||||
<exif:focalLength>50mm</exif:focalLength>
|
||||
<exif:focalLength35mm>75mm</exif:focalLength35mm>
|
||||
<exif:maxAperture>f/1.4</exif:maxAperture>
|
||||
|
||||
<!-- 位置信息 -->
|
||||
<exif:gpsLatitude>39.9042</exif:gpsLatitude>
|
||||
<exif:gpsLongitude>116.4074</exif:gpsLongitude>
|
||||
<exif:altitude>50m</exif:altitude>
|
||||
<exif:location><![CDATA[北京天安门广场]]></exif:location>
|
||||
|
||||
|
||||
<!-- 技术参数 -->
|
||||
<exif:whiteBalance>Auto</exif:whiteBalance>
|
||||
<exif:meteringMode>Matrix</exif:meteringMode>
|
||||
|
||||
Reference in New Issue
Block a user