Skip to content

Commit e15d066

Browse files
authored
feat(rss): enhance rss feed with thumbnail and detailed exif info, and fix feed link (#169)
1 parent 7fbb82c commit e15d066

File tree

2 files changed

+158
-40
lines changed

2 files changed

+158
-40
lines changed

packages/utils/src/rss.ts

Lines changed: 142 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import type { PhotoManifestItem } from '@afilmory/builder'
22

33
const GENERATOR_NAME = 'Afilmory Feed Generator'
4+
const EXIF_NAMESPACE = 'https://afilmory.com/rss/exif'
5+
const PROTOCOL_VERSION = '1.1'
6+
const PROTOCOL_ID = 'afilmory-rss-exif'
47

58
export interface FeedSiteAuthor {
69
name: string
@@ -29,7 +32,7 @@ export function generateRSSFeed(photos: readonly PhotoManifestItem[], config: Fe
2932
const managingEditor = author && config.author?.url ? `${author} (${config.author.url})` : author
3033

3134
return `<?xml version="1.0" encoding="UTF-8"?>
32-
<rss version="2.0">
35+
<rss version="2.0" xmlns:exif="${EXIF_NAMESPACE}">
3336
<channel>
3437
<title>${escapeXml(config.title)}</title>
3538
<link>${baseUrl}</link>
@@ -38,6 +41,8 @@ export function generateRSSFeed(photos: readonly PhotoManifestItem[], config: Fe
3841
<lastBuildDate>${lastBuildDate}</lastBuildDate>
3942
<generator>${GENERATOR_NAME}</generator>
4043
${managingEditor ? `<managingEditor>${managingEditor}</managingEditor>` : ''}
44+
<exif:version>${PROTOCOL_VERSION}</exif:version>
45+
<exif:protocol>${PROTOCOL_ID}</exif:protocol>
4146
${itemsXml}
4247
</channel>
4348
</rss>`
@@ -53,16 +58,152 @@ function createItemXml(photo: PhotoManifestItem, baseUrl: string): string {
5358
? photo.tags.map((tag) => ` <category>${escapeXml(tag)}</category>`).join('\n')
5459
: ''
5560

61+
// Add enclosure for thumbnail if available
62+
// Assuming thumbnail is an image, default to image/jpeg if extension is unknown, but usually it's webp or jpg
63+
let enclosure = ''
64+
if (photo.thumbnailUrl) {
65+
const thumbUrl = photo.thumbnailUrl.startsWith('http')
66+
? photo.thumbnailUrl
67+
: `${baseUrl}${photo.thumbnailUrl.startsWith('/') ? '' : '/'}${photo.thumbnailUrl}`
68+
// Simple mime type guess
69+
const mimeType = thumbUrl.endsWith('.webp') ? 'image/webp' : thumbUrl.endsWith('.png') ? 'image/png' : 'image/jpeg'
70+
enclosure = ` <enclosure url="${escapeXml(thumbUrl)}" type="${mimeType}" length="0" />`
71+
}
72+
73+
const exifTags = buildExifTags(photo)
74+
5675
return ` <item>
5776
<title>${title}</title>
5877
<link>${link}</link>
5978
<guid isPermaLink="false">${escapeXml(photo.id)}</guid>
6079
<pubDate>${pubDate}</pubDate>
6180
<description><![CDATA[${summary}]]></description>
6281
${categories}
82+
${enclosure}
83+
${exifTags}
6384
</item>`
6485
}
6586

87+
function buildExifTags(photo: PhotoManifestItem): string {
88+
if (!photo.exif) return ''
89+
90+
const tags: string[] = []
91+
const { exif } = photo
92+
93+
// --- Basic Camera Settings ---
94+
if (exif.FNumber) {
95+
tags.push(`<exif:aperture>f/${exif.FNumber}</exif:aperture>`)
96+
}
97+
if (exif.ExposureTime) {
98+
// Format shutter speed: if < 1, use fraction, else use seconds
99+
let ss = String(exif.ExposureTime)
100+
if (typeof exif.ExposureTime === 'number') {
101+
if (exif.ExposureTime < 1 && exif.ExposureTime > 0) {
102+
ss = `1/${Math.round(1 / exif.ExposureTime)}s`
103+
} else {
104+
ss = `${exif.ExposureTime}s`
105+
}
106+
} else if (!ss.endsWith('s') && // If it's a string and doesn't end with s, append it?
107+
// Actually exiftool usually gives nice strings or numbers.
108+
// Let's just trust the value but ensure 's' suffix if it looks like a number
109+
!Number.isNaN(Number(ss))) {
110+
ss = `${ss}s`
111+
}
112+
tags.push(`<exif:shutterSpeed>${ss}</exif:shutterSpeed>`)
113+
}
114+
if (exif.ISO) {
115+
tags.push(`<exif:iso>${exif.ISO}</exif:iso>`)
116+
}
117+
if (exif.ExposureCompensation !== undefined && exif.ExposureCompensation !== null) {
118+
const val = Number(exif.ExposureCompensation)
119+
const sign = val > 0 ? '+' : ''
120+
tags.push(`<exif:exposureCompensation>${sign}${val} EV</exif:exposureCompensation>`)
121+
}
122+
123+
// --- Lens Parameters ---
124+
if (exif.FocalLength) {
125+
// Ensure 'mm' suffix
126+
const fl = String(exif.FocalLength).replace('mm', '').trim()
127+
tags.push(`<exif:focalLength>${fl}mm</exif:focalLength>`)
128+
}
129+
if (exif.FocalLengthIn35mmFormat) {
130+
const fl35 = String(exif.FocalLengthIn35mmFormat).replace('mm', '').trim()
131+
tags.push(`<exif:focalLength35mm>${fl35}mm</exif:focalLength35mm>`)
132+
}
133+
if (exif.LensModel) {
134+
tags.push(`<exif:lens><![CDATA[${exif.LensModel}]]></exif:lens>`)
135+
}
136+
if (exif.MaxApertureValue) {
137+
tags.push(`<exif:maxAperture>f/${exif.MaxApertureValue}</exif:maxAperture>`)
138+
}
139+
140+
// --- Device Info ---
141+
const camera = [exif.Make, exif.Model].filter(Boolean).join(' ')
142+
if (camera) {
143+
tags.push(`<exif:camera><![CDATA[${camera}]]></exif:camera>`)
144+
}
145+
146+
// --- Image Attributes ---
147+
if (photo.width) {
148+
tags.push(`<exif:imageWidth>${photo.width}</exif:imageWidth>`)
149+
}
150+
if (photo.height) {
151+
tags.push(`<exif:imageHeight>${photo.height}</exif:imageHeight>`)
152+
}
153+
if (photo.dateTaken) {
154+
tags.push(`<exif:dateTaken>${photo.dateTaken}</exif:dateTaken>`)
155+
}
156+
if (exif.Orientation) {
157+
tags.push(`<exif:orientation>${exif.Orientation}</exif:orientation>`)
158+
}
159+
160+
// --- Location Info ---
161+
// Location info removed as per user request
162+
163+
// Location name is not directly in standard exif usually, but if we had it in photo info...
164+
// Currently PhotoManifestItem doesn't seem to have a dedicated location name field other than maybe tags or description.
165+
// We'll skip <exif:location> for now unless we find a source.
166+
167+
// --- Technical Parameters ---
168+
if (exif.WhiteBalance) {
169+
tags.push(`<exif:whiteBalance>${exif.WhiteBalance}</exif:whiteBalance>`)
170+
}
171+
if (exif.MeteringMode) {
172+
tags.push(`<exif:meteringMode>${exif.MeteringMode}</exif:meteringMode>`)
173+
}
174+
// Flash is often a complex object or string in exiftool, simplify if possible or just dump string
175+
if (exif.Flash) {
176+
// Try to map to simple enum if possible, or just use what we have if it's readable
177+
tags.push(`<exif:flashMode>${String(exif.Flash)}</exif:flashMode>`)
178+
}
179+
if (exif.ColorSpace) {
180+
tags.push(`<exif:colorSpace>${exif.ColorSpace}</exif:colorSpace>`)
181+
}
182+
183+
// --- Advanced Parameters ---
184+
if (exif.ExposureProgram) {
185+
tags.push(`<exif:exposureProgram>${exif.ExposureProgram}</exif:exposureProgram>`)
186+
}
187+
if (exif.SceneCaptureType) {
188+
tags.push(`<exif:sceneMode><![CDATA[${exif.SceneCaptureType}]]></exif:sceneMode>`)
189+
}
190+
191+
// Try to extract from FujiRecipe if available
192+
if (exif.FujiRecipe) {
193+
if (exif.FujiRecipe.Sharpness) {
194+
tags.push(`<exif:sharpness>${exif.FujiRecipe.Sharpness}</exif:sharpness>`)
195+
}
196+
if (exif.FujiRecipe.Saturation) {
197+
tags.push(`<exif:saturation>${exif.FujiRecipe.Saturation}</exif:saturation>`)
198+
}
199+
// Contrast is often "HighlightTone" and "ShadowTone" combined in Fuji,
200+
// or maybe just map one of them? The spec asks for Contrast.
201+
// Let's skip Contrast for FujiRecipe to avoid confusion unless we have a direct mapping.
202+
}
203+
204+
return tags.map((t) => ` ${t}`).join('\n')
205+
}
206+
66207
function buildDescription(photo: PhotoManifestItem): string {
67208
const segments: string[] = []
68209
if (photo.description) {

rss-spec.md

Lines changed: 16 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -131,40 +131,23 @@
131131

132132
#### `<exif:orientation>`
133133

134-
**描述**: 图像方向
135-
**格式**: 整数 (1-8)
136-
**示例**: `<exif:orientation>1</exif:orientation>`
134+
**描述**: 图像方向
135+
**格式**: 整数 (1-8)
136+
**示例**: `<exif:orientation>1</exif:orientation>`
137137
**映射**: EXIF Orientation 字段
138138

139-
### 位置信息 (location)
139+
### 图片展示 (media)
140140

141-
#### `<exif:gpsLatitude>`
141+
#### `<enclosure>`
142142

143-
**描述**: GPS纬度
144-
**格式**: 十进制度数
145-
**示例**: `<exif:gpsLatitude>39.9042</exif:gpsLatitude>`
146-
**映射**: EXIF GPSLatitude 字段
147-
148-
#### `<exif:gpsLongitude>`
149-
150-
**描述**: GPS经度
151-
**格式**: 十进制度数
152-
**示例**: `<exif:gpsLongitude>116.4074</exif:gpsLongitude>`
153-
**映射**: EXIF GPSLongitude 字段
154-
155-
#### `<exif:altitude>`
156-
157-
**描述**: 海拔高度
158-
**格式**: `{数值}m`
159-
**示例**: `<exif:altitude>1200m</exif:altitude>`
160-
**映射**: EXIF GPSAltitude 字段
161-
162-
#### `<exif:location>`
163-
164-
**描述**: 拍摄地点名称
165-
**格式**: CDATA 包装的字符串
166-
**示例**: `<exif:location><![CDATA[北京天安门广场]]></exif:location>`
167-
**映射**: 地理编码或用户标注
143+
**描述**: 缩略图 URL
144+
**位置**: `<item>` 元素内
145+
**格式**: 标准 RSS enclosure 元素
146+
**示例**: `<enclosure url="https://example.com/thumbnails/photo.webp" type="image/webp" length="0" />`
147+
**说明**:
148+
- `url`: 缩略图的完整 URL,支持相对路径转换为绝对路径
149+
- `type`: MIME 类型,根据文件扩展名自动判断(webp/png/jpeg)
150+
- `length`: 文件大小(字节),可设为 0 表示未知
168151

169152
### 技术参数 (technical)
170153

@@ -245,12 +228,12 @@
245228

246229
```xml
247230
<?xml version="1.0" encoding="UTF-8"?>
248-
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" >
231+
<rss version="2.0" xmlns:exif="https://afilmory.com/rss/exif">
249232
<channel>
250233
<title><![CDATA[我的风景摄影画廊]]></title>
251234
<link>https://example.com</link>
252235
<description><![CDATA[分享我的风景摄影作品]]></description>
253-
236+
254237
<!-- 协议元数据 -->
255238
<exif:version>1.1</exif:version>
256239
<exif:protocol>afilmory-rss-exif</exif:protocol>
@@ -282,13 +265,7 @@
282265
<exif:focalLength>50mm</exif:focalLength>
283266
<exif:focalLength35mm>75mm</exif:focalLength35mm>
284267
<exif:maxAperture>f/1.4</exif:maxAperture>
285-
286-
<!-- 位置信息 -->
287-
<exif:gpsLatitude>39.9042</exif:gpsLatitude>
288-
<exif:gpsLongitude>116.4074</exif:gpsLongitude>
289-
<exif:altitude>50m</exif:altitude>
290-
<exif:location><![CDATA[北京天安门广场]]></exif:location>
291-
268+
292269
<!-- 技术参数 -->
293270
<exif:whiteBalance>Auto</exif:whiteBalance>
294271
<exif:meteringMode>Matrix</exif:meteringMode>

0 commit comments

Comments
 (0)