mirror of
https://github.com/DIYgod/RSSHub.git
synced 2026-10-18 14:29:01 +08:00
a442c97770
* fix(utils): parse abbreviated relative date units YouTube serves a compact metadata variant to some sessions, where a video published a year ago reads `1y ago` instead of `1 year ago`. Neither `y`, `w` nor `mo` was recognised, so those strings fell through to the plain Day.js parse and came back as `Invalid Date`. `mo` needs a lookahead on the minutes pattern too: the bare `m` matched the `mo` of a month, so `11 months ago` resolved to eleven months *and* eleven minutes ago, and `3mo ago` to three minutes ago. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(route/youtube): add live streams `/youtube/live` searches for ongoing broadcasts through the Data API, so there is no way to follow a channel's schedule or its past streams. Read the channel's Live tab instead, which lists all three, and let the route params pick which of them end up in the feed. No API key needed. A stream is classified by its thumbnail badge: an ongoing one carries a `LIVE` badge and a scheduled one an `Upcoming` badge. The absence of a duration badge is deliberately not used as the signal, because YouTube groups durations past a thousand hours, e.g. `20,772:51:34` — the same reason the duration attachment now strips separators before summing. Scheduled streams have no publish date, so they take the time they are scheduled for. YouTube renders it in the time zone youtubei.js reports, which is the local one, hence the plain local parse. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(utils): hoist parse-date test helper to module scope `p` captured nothing from the `describe` scope, which tripped `unicorn/consistent-function-scoping`. Moving it out also lets its parameters be derived from `parseRelativeDate` instead of `any[]`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(route/youtube): add stream descriptions The lockups on the Live tab carry only a title, so every item in `/youtube/streams` had an empty body. Add `includeDescription`, which fills it from the player response. That is one request per stream, hence a default of false and only the streams that survive the state filter being fetched. A stream can be unplayable, e.g. a members-only one, so a failed lookup falls back to an empty description instead of taking the whole feed down. The cached value is wrapped in an object because an empty string comes back out of `cache.tryGet` as a literal `""`, and its expiration is deliberately not renewed on a hit, so an edited description still reaches a feed that is polled steadily. `isIncluded` becomes `isEnabled` to give the new parameter a default of false while the `include*` ones keep parsing exactly as before. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(route/youtube): categorize streams instead of filtering them The `includeLive` / `includeUpcoming` / `includeCompleted` route params reimplemented, per route, what the common `filter_category` params already do. Drop them and expose the state that drove them as the item category, so `/youtube/streams/@GawrGura?filterout_category=completed` replaces `/youtube/streams/@GawrGura/includeCompleted=false`. The category lives in `getStreamsByChannelId` rather than in the shared `lockupViewToItem`, which also serves the Videos tab and playlists where an uploaded video is not a `completed` stream. With the filtering gone, `allowEmpty` goes too. It only ever covered a feed emptied by those params: a channel that has never streamed has no Live tab at all, so `getLiveStreams()` throws rather than returning nothing, and the empty check runs before the `filter_category` pass. An empty list now means the lockup parsing broke, which should surface. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
207 lines
8.0 KiB
TypeScript
207 lines
8.0 KiB
TypeScript
import dayjs from 'dayjs';
|
|
import weekday from 'dayjs/plugin/weekday.js';
|
|
import MockDate from 'mockdate';
|
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
|
|
import { parseRelativeDate } from './parse-date';
|
|
|
|
dayjs.extend(weekday);
|
|
|
|
const p = (...args: Parameters<typeof parseRelativeDate>) => parseRelativeDate(...args).getTime();
|
|
|
|
describe('parseRelativeDate', () => {
|
|
// === CONSTANTS ===
|
|
const second = 1000;
|
|
const minute = 60 * second;
|
|
const hour = 60 * minute;
|
|
const day = 24 * hour;
|
|
const week = 7 * day;
|
|
|
|
// === BASE DATE SETUP: 2026-02-02 (Monday) ===
|
|
const BASE_STR = '2026-02-02T12:00:00';
|
|
const NOW_TIMESTAMP = new Date(BASE_STR).getTime();
|
|
|
|
// Derived Timestamps
|
|
const TODAY_START = new Date('2026-02-02T00:00:00').getTime();
|
|
const YESTERDAY_START = TODAY_START - day;
|
|
const TOMORROW_START = TODAY_START + day;
|
|
|
|
// Strict Past Logic Expectations (Reference is Mon Feb 02)
|
|
const PREVIOUS_MONDAY = TODAY_START - week; // Jan 26 (Last week)
|
|
const PREVIOUS_WEDNESDAY = TODAY_START + 2 * day - week; // Jan 28 (Last week)
|
|
const LAST_SUNDAY = new Date('2026-02-01T00:00:00').getTime(); // Yesterday (Feb 01)
|
|
const LAST_FRIDAY = new Date('2026-01-30T00:00:00').getTime(); // Last Friday (Jan 30)
|
|
|
|
beforeEach(() => {
|
|
MockDate.set(NOW_TIMESTAMP);
|
|
});
|
|
|
|
afterEach(() => {
|
|
MockDate.reset();
|
|
});
|
|
|
|
/**
|
|
* Category 1: Immediate & Fuzzy Semantics
|
|
*/
|
|
describe('Immediate & Fuzzy Semantics', () => {
|
|
it('handles "Just now" / "刚刚"', () => {
|
|
const expected = NOW_TIMESTAMP - 3 * second;
|
|
expect(p('Just now')).toBe(expected);
|
|
expect(p('just now')).toBe(expected);
|
|
expect(p('刚刚')).toBe(expected);
|
|
});
|
|
|
|
it('handles vague quantifiers (x / 几)', () => {
|
|
// "几" maps to 3
|
|
expect(p('几分钟前')).toBe(NOW_TIMESTAMP - 3 * minute);
|
|
expect(p('幾分鐘前')).toBe(NOW_TIMESTAMP - 3 * minute);
|
|
expect(p('数秒前')).toBe(NOW_TIMESTAMP - 3 * second);
|
|
|
|
// "x" maps to 3
|
|
expect(p('x days ago')).toBe(NOW_TIMESTAMP - 3 * day);
|
|
expect(p('X seconds ago')).toBe(NOW_TIMESTAMP - 3 * second);
|
|
|
|
// "a/an" maps to 1
|
|
expect(p('a minute ago')).toBe(NOW_TIMESTAMP - 1 * minute);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Category 2: Relative Duration (Ago / In)
|
|
*/
|
|
describe('Relative Duration', () => {
|
|
it('handles past (ago / 前)', () => {
|
|
expect(p('10m ago')).toBe(NOW_TIMESTAMP - 10 * minute);
|
|
expect(p('2 hours ago')).toBe(NOW_TIMESTAMP - 2 * hour);
|
|
expect(p('10秒前')).toBe(NOW_TIMESTAMP - 10 * second);
|
|
});
|
|
|
|
it('handles future (in / later / 后)', () => {
|
|
expect(p('in 10m')).toBe(NOW_TIMESTAMP + 10 * minute);
|
|
expect(p('2 hours later')).toBe(NOW_TIMESTAMP + 2 * hour);
|
|
expect(p('10分钟后')).toBe(NOW_TIMESTAMP + 10 * minute);
|
|
expect(p('10 分鐘後')).toBe(NOW_TIMESTAMP + 10 * minute);
|
|
});
|
|
|
|
it('handles abbreviated units', () => {
|
|
expect(p('1y ago')).toBe(dayjs(NOW_TIMESTAMP).subtract(1, 'year').valueOf());
|
|
expect(p('3mo ago')).toBe(dayjs(NOW_TIMESTAMP).subtract(3, 'month').valueOf());
|
|
expect(p('2w ago')).toBe(NOW_TIMESTAMP - 2 * week);
|
|
expect(p('5d ago')).toBe(NOW_TIMESTAMP - 5 * day);
|
|
});
|
|
|
|
it('does not read the "m" of a month as a minute', () => {
|
|
expect(p('11 months ago')).toBe(dayjs(NOW_TIMESTAMP).subtract(11, 'month').valueOf());
|
|
expect(p('11mo ago')).toBe(dayjs(NOW_TIMESTAMP).subtract(11, 'month').valueOf());
|
|
});
|
|
|
|
it('handles mixed units', () => {
|
|
expect(p('1d 1h ago')).toBe(NOW_TIMESTAMP - (day + hour));
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Category 3: Semantic Keywords & Strict Past Logic
|
|
* Reference: Today is Monday, Feb 02.
|
|
*/
|
|
describe('Semantic Keywords & Weekdays', () => {
|
|
it('handles Today/Yesterday/Tomorrow', () => {
|
|
expect(p('Today')).toBe(TODAY_START);
|
|
expect(p('Yesterday')).toBe(YESTERDAY_START);
|
|
expect(p('Tomorrow')).toBe(TOMORROW_START);
|
|
});
|
|
|
|
it('handles "Monday" (Strict Past: Today is Mon -> Last Mon)', () => {
|
|
// Strict past rule: If input weekday is Same as today, go back 1 week.
|
|
expect(p('Monday')).toBe(PREVIOUS_MONDAY);
|
|
expect(p('周一')).toBe(PREVIOUS_MONDAY);
|
|
expect(p('星期一')).toBe(PREVIOUS_MONDAY);
|
|
});
|
|
|
|
it('handles "Monday 3pm" (Strict Past)', () => {
|
|
expect(p('Monday 3pm')).toBe(PREVIOUS_MONDAY + 15 * hour);
|
|
expect(p('周一 15:00')).toBe(PREVIOUS_MONDAY + 15 * hour);
|
|
});
|
|
|
|
it('handles "Wednesday" (Strict Past: Wed is Future -> Last Wed)', () => {
|
|
// Strict past rule: If input weekday is Future, go back 1 week.
|
|
// Today is Mon Feb 02. Wed is Feb 04 (Future) -> Back 1 week -> Jan 28.
|
|
expect(p('Wednesday')).toBe(PREVIOUS_WEDNESDAY);
|
|
expect(p('周三')).toBe(PREVIOUS_WEDNESDAY);
|
|
});
|
|
|
|
it.each([
|
|
['Tuesday', '2026-01-27T00:00:00'],
|
|
['Tue', '2026-01-27T00:00:00'],
|
|
['Thursday', '2026-01-29T00:00:00'],
|
|
['Thu', '2026-01-29T00:00:00'],
|
|
['Tuesday 3pm', '2026-01-27T15:00:00'],
|
|
['Thu 10:00', '2026-01-29T10:00:00'],
|
|
])('parses %s as a past weekday instead of tomorrow', (input, expected) => {
|
|
expect(p(input)).toBe(new Date(expected).getTime());
|
|
});
|
|
|
|
it.each(['Tomorrow', 't'])('preserves %s as tomorrow with optional time', (input) => {
|
|
expect(p(input)).toBe(TOMORROW_START);
|
|
expect(p(`${input}3pm`)).toBe(TOMORROW_START + 15 * hour);
|
|
expect(p(`${input} 10:00`)).toBe(TOMORROW_START + 10 * hour);
|
|
});
|
|
|
|
it('handles "Sunday" (Past: Sun is Yesterday -> Feb 01)', () => {
|
|
// Sunday (Feb 01) is strictly before Monday (Feb 02).
|
|
expect(p('Sunday')).toBe(LAST_SUNDAY);
|
|
});
|
|
|
|
it('handles "Friday" (Past: Fri is Jan 30)', () => {
|
|
expect(p('Friday')).toBe(LAST_FRIDAY);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Category 4: Contextual & Combined Expressions
|
|
*/
|
|
describe('Contextual & Formatted Time', () => {
|
|
it('handles sticky AM/PM', () => {
|
|
expect(p('Today 3pm')).toBe(TODAY_START + 15 * hour);
|
|
expect(p('Yesterday 10pm')).toBe(YESTERDAY_START + 22 * hour);
|
|
});
|
|
|
|
it('handles spaced AM/PM', () => {
|
|
expect(p('Today 3 pm')).toBe(TODAY_START + 15 * hour);
|
|
});
|
|
|
|
it('handles Chinese modifiers (Morning/Evening)', () => {
|
|
// "明早" -> Tomorrow 8am
|
|
expect(p('明早8点')).toBe(TOMORROW_START + 8 * hour);
|
|
// "昨晚" -> Yesterday 8pm (20:00)
|
|
expect(p('昨晚8点')).toBe(YESTERDAY_START + 20 * hour);
|
|
// "周五下午3点" -> Last Friday 15:00
|
|
expect(p('周五下午3点')).toBe(LAST_FRIDAY + 15 * hour);
|
|
// https://github.com/DIYgod/RSSHub/issues/20878
|
|
expect(p('昨天 23:01')).toBe(YESTERDAY_START + 23 * hour + 1 * minute);
|
|
});
|
|
|
|
it('handles 12am / 12pm edge cases', () => {
|
|
expect(p('Today 12pm')).toBe(TODAY_START + 12 * hour); // Noon
|
|
expect(p('Today 12am')).toBe(TODAY_START); // Midnight
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Category 5: Edge Cases & Fallback
|
|
*/
|
|
describe('Fallback', () => {
|
|
it('passes formatting options to dayjs', () => {
|
|
const str = '05/02/2026';
|
|
const format = 'DD/MM/YYYY'; // Feb 5th
|
|
const expected = new Date('2026-02-05T00:00:00').getTime();
|
|
expect(p(str, format)).toBe(expected);
|
|
});
|
|
|
|
it('returns raw absolute dates', () => {
|
|
const raw = '2026-01-01T00:00:00';
|
|
expect(p(raw)).toBe(new Date(raw).getTime());
|
|
});
|
|
});
|
|
});
|