forked from DIYgod/RSSHub
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
fix(utils): parse relative dates with multiple time units (DIYgod#9365)
* fix(utils): parse relative dates with multiple time units * docs: remove warning * fix: add more characters to match * fix: rename to parse-date
- Loading branch information
Showing
3 changed files
with
317 additions
and
24 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,42 +1,195 @@ | ||
const dayjs = require('dayjs'); | ||
dayjs.extend(require('dayjs/plugin/customParseFormat')); | ||
dayjs.extend(require('dayjs/plugin/duration')); | ||
dayjs.extend(require('dayjs/plugin/weekday')); | ||
|
||
const words = [ | ||
{ | ||
startAt: dayjs(), | ||
regExp: /^(?:今(?:天|日)|t(?:o)?da(?:y)?)(.*)/, | ||
}, | ||
{ | ||
startAt: dayjs().subtract(1, 'days'), | ||
regExp: /^(?:昨(?:天|日)|y(?:ester)?da(?:y)?)(.*)/, | ||
}, | ||
{ | ||
startAt: dayjs().subtract(2, 'days'), | ||
regExp: /^(?:前天|(?:the)?d(?:ay)?b(?:eforeyesterda)?y)(.*)/, | ||
}, | ||
{ | ||
startAt: dayjs().weekday(1), | ||
regExp: /^(?:周|星期)一(.*)/, | ||
}, | ||
{ | ||
startAt: dayjs().weekday(2), | ||
regExp: /^(?:周|星期)二(.*)/, | ||
}, | ||
{ | ||
startAt: dayjs().weekday(3), | ||
regExp: /^(?:周|星期)三(.*)/, | ||
}, | ||
{ | ||
startAt: dayjs().weekday(4), | ||
regExp: /^(?:周|星期)四(.*)/, | ||
}, | ||
{ | ||
startAt: dayjs().weekday(5), | ||
regExp: /^(?:周|星期)五(.*)/, | ||
}, | ||
{ | ||
startAt: dayjs().weekday(6), | ||
regExp: /^(?:周|星期)六(.*)/, | ||
}, | ||
{ | ||
startAt: dayjs().weekday(7), | ||
regExp: /^(?:周|星期)(?:日|天)(.*)/, | ||
}, | ||
{ | ||
startAt: dayjs().add(1, 'days'), | ||
regExp: /^(?:明(?:天|日)|y(?:ester)?da(?:y)?)(.*)/, | ||
}, | ||
{ | ||
startAt: dayjs().add(2, 'days'), | ||
regExp: /^(?:(?:后|後)天|(?:the)?d(?:ay)?b(?:eforeyesterda)?y)(.*)/, | ||
}, | ||
]; | ||
|
||
const patterns = [ | ||
{ | ||
regexp: /^(\d+)分钟前$/, | ||
handler: (minute) => dayjs().subtract(minute, 'minutes'), | ||
unit: 'years', | ||
regExp: /(\d+)(?:年|y(?:ea)?r(?:s)?)/, | ||
}, | ||
{ | ||
unit: 'months', | ||
regExp: /(\d+)(?:(?:个|個)?月|month(?:s)?)/, | ||
}, | ||
{ | ||
regexp: /^(\d+)小时前$/, | ||
handler: (hour) => dayjs().subtract(hour, 'hours'), | ||
unit: 'weeks', | ||
regExp: /(\d+)(?:周|(?:个|個)?星期|week(?:s)?)/, | ||
}, | ||
{ | ||
regexp: /^(\d+)天前$/, | ||
handler: (day) => dayjs().subtract(day, 'days'), | ||
unit: 'days', | ||
regExp: /(\d+)(?:天|日|day(?:s)?)/, | ||
}, | ||
{ | ||
regexp: /^今天\s*((\d+:\d+)?)$/, | ||
handler: (hm) => dayjs(hm || '0:0', ['HH:m', 'HH:mm', 'H:m', 'H:mm']), | ||
unit: 'hours', | ||
regExp: /(\d+)(?:(?:个|個)?(?:(?:小)?时|時|点|點)|h(?:ou)?r(?:s)?)/, | ||
}, | ||
{ | ||
regexp: /^昨天\s*((\d+:\d+)?)$/, | ||
handler: (hm) => dayjs(hm || '0:0', ['HH:m', 'HH:mm', 'H:m', 'H:mm']).subtract(1, 'day'), | ||
unit: 'minutes', | ||
regExp: /(\d+)(?:分(?:钟|鐘)?|min(?:ute)?(?:s)?)/, | ||
}, | ||
{ | ||
regexp: /^前天\s*((\d+:\d+)?)$/, | ||
handler: (hm) => dayjs(hm || '0:0', ['HH:m', 'HH:mm', 'H:m', 'H:mm']).subtract(2, 'day'), | ||
unit: 'seconds', | ||
regExp: /(\d+)(?:秒(?:钟|鐘)?|sec(?:ond)?(?:s)?)/, | ||
}, | ||
]; | ||
|
||
const patternSize = Object.keys(patterns).length; | ||
|
||
/** | ||
* 预处理日期字符串 | ||
* @param {String} date 原始日期字符串 | ||
*/ | ||
const toDate = (date) => | ||
date | ||
.toLowerCase() | ||
.replace(/(^a(?:n)?\s)|(\sa(?:n)?\s)/g, '1') // 替换 `a` 和 `an` 为 `1` | ||
.replace(/几|幾/g, '3') // 如 `几秒钟前` 视作 `3秒钟前` | ||
.replace(/[\s,]/g, ''); // 移除所有空格 | ||
|
||
/** | ||
* 将 `['\d+时', ..., '\d+秒']` 转换为 `{ hours: \d+, ..., seconds: \d+ }` | ||
* 用于描述时间长度 | ||
* @param {Array.<String>} matches 所有匹配结果 | ||
*/ | ||
const toDurations = (matches) => { | ||
const durations = {}; | ||
|
||
let p = 0; | ||
for (const m of matches) { | ||
for (; p <= patternSize; p++) { | ||
const match = patterns[p].regExp.exec(m); | ||
if (match) { | ||
durations[patterns[p].unit] = match[1]; | ||
break; | ||
} | ||
} | ||
} | ||
return durations; | ||
}; | ||
|
||
module.exports = { | ||
parseDate: (date, ...options) => dayjs(date, ...options).toDate(), | ||
parseRelativeDate: (date) => { | ||
for (const pattern of patterns) { | ||
const match = pattern.regexp.exec(date); | ||
if (match !== null) { | ||
return pattern.handler(match[1]).toDate(); | ||
// 预处理日期字符串 date | ||
|
||
const theDate = toDate(date); | ||
|
||
// 将 `\d+年\d+月...\d+秒前` 分割成 `['\d+年', ..., '\d+秒前']` | ||
|
||
const matches = theDate.match(/(?:\D+)?\d+(?!:|-|\/)\D+/g); | ||
|
||
if (matches) { | ||
// 获得最后的时间单元,如 `\d+秒前` | ||
|
||
const lastMatch = matches.pop(); | ||
|
||
// 若最后的时间单元含有 `前`、`以前`、`之前` 等标识字段,减去相应的时间长度 | ||
// 如 `1分10秒前` | ||
|
||
const beforeMatches = /(.*)(?:(?:之|以)?前|ago)$/.exec(lastMatch); | ||
if (beforeMatches) { | ||
matches.push(beforeMatches[1]); | ||
return dayjs() | ||
.subtract(dayjs.duration(toDurations(matches))) | ||
.toDate(); | ||
} | ||
|
||
// 若最后的时间单元含有 `后`、`以后`、`之后` 等标识字段,加上相应的时间长度 | ||
// 如 `1分10秒后` | ||
|
||
const afterMatches = /(?:^in(.*)|(.*)(?:之|以)?(?:后|後))$/.exec(lastMatch); | ||
if (afterMatches) { | ||
matches.push(afterMatches[1] ?? afterMatches[2]); | ||
return dayjs() | ||
.add(dayjs.duration(toDurations(matches))) | ||
.toDate(); | ||
} | ||
|
||
// 以下处理日期字符串 date 含有特殊词的情形 | ||
// 如 `今天1点10分` | ||
|
||
matches.push(lastMatch); | ||
const firstMatch = matches.shift(); | ||
|
||
for (const w of words) { | ||
const wordMatches = w.regExp.exec(firstMatch); | ||
if (wordMatches) { | ||
matches.unshift(wordMatches[1]); | ||
|
||
// 取特殊词对应日零时为起点,加上相应的时间长度 | ||
|
||
return w.startAt | ||
.set('hour', 0) | ||
.set('minute', 0) | ||
.set('second', 0) | ||
.set('millisecond', 0) | ||
.add(dayjs.duration(toDurations(matches))) | ||
.toDate(); | ||
} | ||
} | ||
} else { | ||
// 若日期字符串 date 不匹配 patterns 中所有模式,则默认为 `特殊词 + 标准时间格式` 的情形,此时直接将特殊词替换为对应日期 | ||
// 如今天为 `2022-03-22`,则 `今天 20:00` => `2022-03-22 20:00` | ||
|
||
for (const w of words) { | ||
const wordMatches = w.regExp.exec(theDate); | ||
if (wordMatches) { | ||
return dayjs(`${w.startAt.format('YYYY-MM-DD')} ${wordMatches[1]}`).toDate(); | ||
} | ||
} | ||
} | ||
return null; | ||
|
||
return date; | ||
}, | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,144 @@ | ||
const { parseRelativeDate } = require('@/utils/parse-date'); | ||
const MockDate = require('mockdate'); | ||
|
||
describe('parseRelativeDate', () => { | ||
const date = new Date(); | ||
|
||
MockDate.set(date); | ||
|
||
it('s秒钟前', () => { | ||
expect(+new Date(parseRelativeDate('10秒前'))).toBe(+date - 10 * 1000); | ||
}); | ||
|
||
it('m分钟前', () => { | ||
expect(+new Date(parseRelativeDate('10分钟前'))).toBe(+date - 10 * 60 * 1000); | ||
}); | ||
|
||
it('m分鐘前', () => { | ||
expect(+new Date(parseRelativeDate('10分鐘前'))).toBe(+date - 10 * 60 * 1000); | ||
}); | ||
|
||
it('m分钟后', () => { | ||
expect(+new Date(parseRelativeDate('10分钟后'))).toBe(+date + 10 * 60 * 1000); | ||
}); | ||
|
||
it('a minute ago', () => { | ||
expect(+new Date(parseRelativeDate('a minute ago'))).toBe(+date - 1 * 60 * 1000); | ||
}); | ||
|
||
it('s minutes ago', () => { | ||
expect(+new Date(parseRelativeDate('10 minutes ago'))).toBe(+date - 10 * 60 * 1000); | ||
}); | ||
|
||
it('s mins ago', () => { | ||
expect(+new Date(parseRelativeDate('10 mins ago'))).toBe(+date - 10 * 60 * 1000); | ||
}); | ||
|
||
it('in s minutes', () => { | ||
expect(+new Date(parseRelativeDate('in 10 minutes'))).toBe(+date + 10 * 60 * 1000); | ||
}); | ||
|
||
it('in an hour', () => { | ||
expect(+new Date(parseRelativeDate('in an hour'))).toBe(+date + 1 * 60 * 60 * 1000); | ||
}); | ||
|
||
it('H小时前', () => { | ||
expect(+new Date(parseRelativeDate('10小时前'))).toBe(+date - 10 * 60 * 60 * 1000); | ||
}); | ||
|
||
it('H个小时前', () => { | ||
expect(+new Date(parseRelativeDate('10个小时前'))).toBe(+date - 10 * 60 * 60 * 1000); | ||
}); | ||
|
||
it('D天前', () => { | ||
expect(+new Date(parseRelativeDate('10天前'))).toBe(+date - 10 * 24 * 60 * 60 * 1000); | ||
}); | ||
|
||
it('W周前', () => { | ||
expect(+new Date(parseRelativeDate('10周前'))).toBe(+date - 10 * 7 * 24 * 60 * 60 * 1000); | ||
}); | ||
|
||
it('W星期前', () => { | ||
expect(+new Date(parseRelativeDate('10星期前'))).toBe(+date - 10 * 7 * 24 * 60 * 60 * 1000); | ||
}); | ||
|
||
it('W个星期前', () => { | ||
expect(+new Date(parseRelativeDate('10个星期前'))).toBe(+date - 10 * 7 * 24 * 60 * 60 * 1000); | ||
}); | ||
|
||
it('M月前', () => { | ||
expect(+new Date(parseRelativeDate('1月前'))).toBe(+date - 1 * 30 * 24 * 60 * 60 * 1000); | ||
}); | ||
|
||
it('M个月前', () => { | ||
expect(+new Date(parseRelativeDate('1个月前'))).toBe(+date - 1 * 30 * 24 * 60 * 60 * 1000); | ||
}); | ||
|
||
it('Y年前', () => { | ||
expect(+new Date(parseRelativeDate('1年前'))).toBe(+date - 1 * 365 * 24 * 60 * 60 * 1000); | ||
}); | ||
|
||
it('Y年M个月前', () => { | ||
expect(+new Date(parseRelativeDate('1年1个月前'))).toBe(+date - 1 * 365 * 24 * 60 * 60 * 1000 - 1 * 30 * 24 * 60 * 60 * 1000); | ||
}); | ||
|
||
it('D天H小时前', () => { | ||
expect(+new Date(parseRelativeDate('1天1小时前'))).toBe(+date - 1 * 24 * 60 * 60 * 1000 - 1 * 60 * 60 * 1000); | ||
}); | ||
|
||
it('H小时m分钟s秒钟前', () => { | ||
expect(+new Date(parseRelativeDate('1小时1分钟1秒钟前'))).toBe(+date - 1 * 60 * 60 * 1000 - 1 * 60 * 1000 - 1 * 1000); | ||
}); | ||
|
||
it('H小时m分钟s秒钟后', () => { | ||
expect(+new Date(parseRelativeDate('1小时1分钟1秒钟后'))).toBe(+date + 1 * 60 * 60 * 1000 + 1 * 60 * 1000 + 1 * 1000); | ||
}); | ||
|
||
it('今天', () => { | ||
expect(+new Date(parseRelativeDate('今天'))).toBe(+date.setHours(0, 0, 0, 0)); | ||
}); | ||
|
||
it('Today H:m', () => { | ||
expect(+new Date(parseRelativeDate('Today 08:00'))).toBe(+date + 8 * 60 * 60 * 1000); | ||
}); | ||
|
||
it('TDA H:m:s', () => { | ||
expect(+new Date(parseRelativeDate('TDA 08:00:00'))).toBe(+date + 8 * 60 * 60 * 1000); | ||
}); | ||
|
||
it('今天 H:m', () => { | ||
expect(+new Date(parseRelativeDate('今天 08:00'))).toBe(+date + 8 * 60 * 60 * 1000); | ||
}); | ||
|
||
it('今天H点m分', () => { | ||
expect(+new Date(parseRelativeDate('今天8点0分'))).toBe(+date + 8 * 60 * 60 * 1000); | ||
}); | ||
|
||
it('昨日H点m分s秒', () => { | ||
expect(+new Date(parseRelativeDate('昨日20时0分0秒'))).toBe(+date - 4 * 60 * 60 * 1000); | ||
}); | ||
|
||
it('前天 H:m', () => { | ||
expect(+new Date(parseRelativeDate('前天 20:00'))).toBe(+date - 28 * 60 * 60 * 1000); | ||
}); | ||
|
||
it('明天 H:m', () => { | ||
expect(+new Date(parseRelativeDate('明天 20:00'))).toBe(+date + 44 * 60 * 60 * 1000); | ||
}); | ||
|
||
it('星期几 h:m', () => { | ||
expect(+new Date(parseRelativeDate('星期一 8:00'))).toBe(+new Date(date.getFullYear(), date.getMonth(), date.getDate() + 1 - (date.getDay() || 7)) + 8 * 60 * 60 * 1000); | ||
}); | ||
|
||
it('周几 h:m', () => { | ||
expect(+new Date(parseRelativeDate('周二 8:00'))).toBe(+new Date(date.getFullYear(), date.getMonth(), date.getDate() + 2 - (date.getDay() || 7)) + 8 * 60 * 60 * 1000); | ||
}); | ||
|
||
it('星期天 h:m', () => { | ||
expect(+new Date(parseRelativeDate('星期天 8:00'))).toBe(+new Date(date.getFullYear(), date.getMonth(), date.getDate() + 7 - (date.getDay() || 7)) + 8 * 60 * 60 * 1000); | ||
}); | ||
|
||
it('Invalid', () => { | ||
expect(parseRelativeDate('RSSHub')).toBe('RSSHub'); | ||
}); | ||
}); |