feat(library): core types + rss/atom chapter feed parser
This commit is contained in:
parent
1e7e6d1032
commit
c398669ae6
3 changed files with 260 additions and 0 deletions
97
packages/obsidian/src/library/rss.ts
Normal file
97
packages/obsidian/src/library/rss.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
export interface FeedItem {
|
||||
title: string;
|
||||
date: string; // ISO (YYYY-MM-DD) or ''
|
||||
id: string;
|
||||
}
|
||||
|
||||
const BLOCK_RE = /<(item|entry)\b[^>]*>([\s\S]*?)<\/\1>/gi;
|
||||
|
||||
function extractTag(block: string, tag: string): string | null {
|
||||
const re = new RegExp(`<${tag}\\b[^>]*>([\\s\\S]*?)<\\/${tag}>`, 'i');
|
||||
const m = re.exec(block);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
function unwrapCdata(raw: string): string {
|
||||
const m = /^\s*<!\[CDATA\[([\s\S]*?)\]\]>\s*$/.exec(raw);
|
||||
return m ? m[1] : raw;
|
||||
}
|
||||
|
||||
function decodeEntities(raw: string): string {
|
||||
return raw
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/&/g, '&');
|
||||
}
|
||||
|
||||
function cleanText(raw: string | null): string {
|
||||
if (raw === null) return '';
|
||||
return decodeEntities(unwrapCdata(raw)).trim();
|
||||
}
|
||||
|
||||
function toIsoDate(raw: string | null): string {
|
||||
if (!raw) return '';
|
||||
const d = new Date(cleanText(raw));
|
||||
if (isNaN(d.getTime())) return '';
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* RSS 2.0 <item> + Atom <entry>, regex-based (no DOM libs). Entities decoded
|
||||
* (& < > " '), CDATA unwrapped. Invalid/missing dates → ''.
|
||||
*/
|
||||
export function parseFeed(xml: string): FeedItem[] {
|
||||
const items: FeedItem[] = [];
|
||||
if (!xml) return items;
|
||||
|
||||
let match: RegExpExecArray | null;
|
||||
BLOCK_RE.lastIndex = 0;
|
||||
while ((match = BLOCK_RE.exec(xml)) !== null) {
|
||||
const block = match[2];
|
||||
const title = cleanText(extractTag(block, 'title'));
|
||||
const rawDate = extractTag(block, 'pubDate') ?? extractTag(block, 'updated');
|
||||
const id = cleanText(extractTag(block, 'guid') ?? extractTag(block, 'id'));
|
||||
items.push({ title, date: toIsoDate(rawDate), id });
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
const CHAPTER_RE = /(chapter|ch\.?|#)\s*(\d+(\.\d+)?)/i;
|
||||
|
||||
/** /(chapter|ch\.?|#)\s*(\d+(\.\d+)?)/i → parseFloat; null when no match */
|
||||
export function extractChapterNumber(title: string): number | null {
|
||||
const m = CHAPTER_RE.exec(title);
|
||||
if (!m) return null;
|
||||
return parseFloat(m[2]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Items assumed newest-first. Picks the item with the highest parseable
|
||||
* chapter number; falls back to date ordering (max ISO date) when no item
|
||||
* title yields a parseable chapter number.
|
||||
*/
|
||||
export function latestChapter(items: FeedItem[]): { chapter: number | null; date: string; title: string } | null {
|
||||
if (items.length === 0) return null;
|
||||
|
||||
let best: FeedItem | null = null;
|
||||
let bestChapter: number | null = null;
|
||||
|
||||
for (const item of items) {
|
||||
const chapter = extractChapterNumber(item.title);
|
||||
if (chapter !== null && (bestChapter === null || chapter > bestChapter)) {
|
||||
bestChapter = chapter;
|
||||
best = item;
|
||||
}
|
||||
}
|
||||
|
||||
if (best) return { chapter: bestChapter, date: best.date, title: best.title };
|
||||
|
||||
let latest = items[0];
|
||||
for (const item of items) {
|
||||
if (item.date && (!latest.date || item.date > latest.date)) latest = item;
|
||||
}
|
||||
return { chapter: null, date: latest.date, title: latest.title };
|
||||
}
|
||||
28
packages/obsidian/src/library/types.ts
Normal file
28
packages/obsidian/src/library/types.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
export type HttpJsonFn = (url: string, headers: Record<string, string>) => Promise<any>;
|
||||
export type HttpTextFn = (url: string, headers: Record<string, string>) => Promise<string>;
|
||||
|
||||
export interface LibraryNoteCtx {
|
||||
frontmatter: Record<string, string>;
|
||||
body: string;
|
||||
filename: string;
|
||||
}
|
||||
|
||||
export interface MediaTypeSpec {
|
||||
typeName: 'manga' | 'book' | 'game' | 'comic';
|
||||
itemType: string; // 'manga_item' etc.
|
||||
folderSettingKey: string;
|
||||
enabledSettingKey: string;
|
||||
throttleMs: number;
|
||||
hasId(fm: Record<string, string>): boolean;
|
||||
isActive(fm: Record<string, string>): boolean;
|
||||
resolve(ctx: LibraryNoteCtx, deps: SpecDeps): Promise<Record<string, string> | null>; // returns fm patches (id fields)
|
||||
sync(ctx: LibraryNoteCtx, deps: SpecDeps): Promise<{ content: string; flipped: boolean } | null>; // full new note content; null = skip
|
||||
}
|
||||
|
||||
export interface SpecDeps {
|
||||
http: HttpJsonFn;
|
||||
httpText: HttpTextFn;
|
||||
getKey(name: 'rawg' | 'comicvine'): string; // '' when unset
|
||||
log(msg: string): void;
|
||||
notify(msg: string): void;
|
||||
}
|
||||
135
tests/library-rss.test.ts
Normal file
135
tests/library-rss.test.ts
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
import { describe, expect, test } from 'bun:test';
|
||||
import { extractChapterNumber, latestChapter, parseFeed } from 'packages/obsidian/src/library/rss';
|
||||
|
||||
const rssSample = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0">
|
||||
<channel>
|
||||
<title>Chainsaw Man Updates</title>
|
||||
<item>
|
||||
<title>Chainsaw Man Chapter 214</title>
|
||||
<pubDate>Thu, 30 Jul 2026 12:00:00 GMT</pubDate>
|
||||
<guid>https://example.com/csm-214</guid>
|
||||
</item>
|
||||
<item>
|
||||
<title>Chainsaw Man Chapter 213.5</title>
|
||||
<pubDate>Thu, 23 Jul 2026 12:00:00 GMT</pubDate>
|
||||
<guid>https://example.com/csm-213-5</guid>
|
||||
</item>
|
||||
<item>
|
||||
<title>Chainsaw Man Chapter 213</title>
|
||||
<pubDate>Thu, 16 Jul 2026 12:00:00 GMT</pubDate>
|
||||
<guid>https://example.com/csm-213</guid>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>`;
|
||||
|
||||
const atomSample = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<feed xmlns="http://www.w3.org/2005/Atom">
|
||||
<title>Some Manga Feed</title>
|
||||
<entry>
|
||||
<title>Ch. 12 — name</title>
|
||||
<updated>2026-08-01T09:30:00Z</updated>
|
||||
<id>https://example.com/entry/12</id>
|
||||
</entry>
|
||||
</feed>`;
|
||||
|
||||
const cdataSample = `<rss version="2.0"><channel><item>
|
||||
<title><![CDATA[One Piece Chapter 1120]]></title>
|
||||
<pubDate>Mon, 01 Jun 2026 00:00:00 GMT</pubDate>
|
||||
<guid>id-cdata</guid>
|
||||
</item></channel></rss>`;
|
||||
|
||||
const entitySample = `<rss version="2.0"><channel><item>
|
||||
<title>Attack & Titan 'Special' Chapter 5</title>
|
||||
<pubDate>Mon, 01 Jun 2026 00:00:00 GMT</pubDate>
|
||||
<guid>id-entity</guid>
|
||||
</item></channel></rss>`;
|
||||
|
||||
const numberlessSample = `<rss version="2.0"><channel>
|
||||
<item>
|
||||
<title>Chainsaw Man - Extra Announcement</title>
|
||||
<pubDate>Thu, 30 Jul 2026 12:00:00 GMT</pubDate>
|
||||
<guid>a</guid>
|
||||
</item>
|
||||
<item>
|
||||
<title>Chainsaw Man - Fan Art Contest</title>
|
||||
<pubDate>Thu, 16 Jul 2026 12:00:00 GMT</pubDate>
|
||||
<guid>b</guid>
|
||||
</item>
|
||||
</channel></rss>`;
|
||||
|
||||
describe('parseFeed', () => {
|
||||
test('RSS 2.0 — 3 items, title/date/id mapped, newest first as-authored', () => {
|
||||
const items = parseFeed(rssSample);
|
||||
expect(items).toEqual([
|
||||
{ title: 'Chainsaw Man Chapter 214', date: '2026-07-30', id: 'https://example.com/csm-214' },
|
||||
{ title: 'Chainsaw Man Chapter 213.5', date: '2026-07-23', id: 'https://example.com/csm-213-5' },
|
||||
{ title: 'Chainsaw Man Chapter 213', date: '2026-07-16', id: 'https://example.com/csm-213' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('Atom — entry title/updated/id mapped', () => {
|
||||
const items = parseFeed(atomSample);
|
||||
expect(items).toEqual([{ title: 'Ch. 12 — name', date: '2026-08-01', id: 'https://example.com/entry/12' }]);
|
||||
});
|
||||
|
||||
test('CDATA title unwrapped', () => {
|
||||
const items = parseFeed(cdataSample);
|
||||
expect(items[0].title).toBe('One Piece Chapter 1120');
|
||||
});
|
||||
|
||||
test('entity-encoded title decoded', () => {
|
||||
const items = parseFeed(entitySample);
|
||||
expect(items[0].title).toBe(`Attack & Titan 'Special' Chapter 5`);
|
||||
});
|
||||
|
||||
test('empty xml → []', () => {
|
||||
expect(parseFeed('')).toEqual([]);
|
||||
});
|
||||
|
||||
test('garbage xml → []', () => {
|
||||
expect(parseFeed('not xml at all, just some random text')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractChapterNumber', () => {
|
||||
test('"Chapter 214" → 214', () => {
|
||||
expect(extractChapterNumber('Chapter 214')).toBe(214);
|
||||
});
|
||||
test('"ch.213.5" → 213.5', () => {
|
||||
expect(extractChapterNumber('ch.213.5')).toBe(213.5);
|
||||
});
|
||||
test('"#77" → 77', () => {
|
||||
expect(extractChapterNumber('#77')).toBe(77);
|
||||
});
|
||||
test('"Episode 5" → null', () => {
|
||||
expect(extractChapterNumber('Episode 5')).toBeNull();
|
||||
});
|
||||
test('"Vol. 3 Chapter 21" → 21', () => {
|
||||
expect(extractChapterNumber('Vol. 3 Chapter 21')).toBe(21);
|
||||
});
|
||||
test('numberless title → null', () => {
|
||||
expect(extractChapterNumber('Chainsaw Man - Extra Announcement')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('latestChapter', () => {
|
||||
test('empty items → null', () => {
|
||||
expect(latestChapter([])).toBeNull();
|
||||
});
|
||||
|
||||
test('RSS sample → highest chapter (214) wins', () => {
|
||||
const result = latestChapter(parseFeed(rssSample));
|
||||
expect(result).toEqual({ chapter: 214, date: '2026-07-30', title: 'Chainsaw Man Chapter 214' });
|
||||
});
|
||||
|
||||
test('Atom sample → single entry chapter 12', () => {
|
||||
const result = latestChapter(parseFeed(atomSample));
|
||||
expect(result).toEqual({ chapter: 12, date: '2026-08-01', title: 'Ch. 12 — name' });
|
||||
});
|
||||
|
||||
test('numberless titles → date-based fallback, chapter null', () => {
|
||||
const result = latestChapter(parseFeed(numberlessSample));
|
||||
expect(result).toEqual({ chapter: null, date: '2026-07-30', title: 'Chainsaw Man - Extra Announcement' });
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue