285 lines
9.0 KiB
Plaintext
285 lines
9.0 KiB
Plaintext
import {
|
|
DATE_MAX_YEAR,
|
|
DATE_MIN_YEAR,
|
|
DATE_SPLITS,
|
|
REFERENCE_YEAR,
|
|
} from '../../data/const'
|
|
import { sorted } from '../../utils/helper'
|
|
import { MatcherBaseClass, DateMatch, MatchOptions } from '../../types'
|
|
|
|
type DateMatchOptions = Pick<MatchOptions, 'password'>
|
|
|
|
/*
|
|
* -------------------------------------------------------------------------------
|
|
* date matching ----------------------------------------------------------------
|
|
* -------------------------------------------------------------------------------
|
|
*/
|
|
class MatchDate extends MatcherBaseClass {
|
|
/*
|
|
* a "date" is recognized as:
|
|
* any 3-tuple that starts or ends with a 2- or 4-digit year,
|
|
* with 2 or 0 separator chars (1.1.91 or 1191),
|
|
* maybe zero-padded (01-01-91 vs 1-1-91),
|
|
* a month between 1 and 12,
|
|
* a day between 1 and 31.
|
|
*
|
|
* note: this isn't true date parsing in that "feb 31st" is allowed,
|
|
* this doesn't check for leap years, etc.
|
|
*
|
|
* recipe:
|
|
* start with regex to find maybe-dates, then attempt to map the integers
|
|
* onto month-day-year to filter the maybe-dates into dates.
|
|
* finally, remove matches that are substrings of other matches to reduce noise.
|
|
*
|
|
* note: instead of using a lazy or greedy regex to find many dates over the full string,
|
|
* this uses a ^...$ regex against every substring of the password -- less performant but leads
|
|
* to every possible date match.
|
|
*/
|
|
match({ password }: DateMatchOptions) {
|
|
const matches: DateMatch[] = [
|
|
...this.getMatchesWithoutSeparator(password),
|
|
...this.getMatchesWithSeparator(password),
|
|
]
|
|
|
|
const filteredMatches = this.filterNoise(matches)
|
|
return sorted(filteredMatches)
|
|
}
|
|
|
|
getMatchesWithSeparator(password: string) {
|
|
const matches: DateMatch[] = []
|
|
const maybeDateWithSeparator = /^(\d{1,4})([\s/\\_.-])(\d{1,2})\2(\d{1,4})$/
|
|
// # dates with separators are between length 6 '1/1/91' and 10 '11/11/1991'
|
|
for (let i = 0; i <= Math.abs(password.length - 6); i += 1) {
|
|
for (let j = i + 5; j <= i + 9; j += 1) {
|
|
if (j >= password.length) {
|
|
break
|
|
}
|
|
const token = password.slice(i, j + 1 || 9e9)
|
|
const regexMatch = maybeDateWithSeparator.exec(token)
|
|
if (regexMatch != null) {
|
|
const dmy = this.mapIntegersToDayMonthYear([
|
|
parseInt(regexMatch[1], 10),
|
|
parseInt(regexMatch[3], 10),
|
|
parseInt(regexMatch[4], 10),
|
|
])
|
|
if (dmy != null) {
|
|
matches.push({
|
|
pattern: 'date',
|
|
token,
|
|
i,
|
|
j,
|
|
separator: regexMatch[2],
|
|
year: dmy.year,
|
|
month: dmy.month,
|
|
day: dmy.day,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return matches
|
|
}
|
|
|
|
// eslint-disable-next-line max-statements
|
|
getMatchesWithoutSeparator(password: string) {
|
|
const matches: DateMatch[] = []
|
|
const maybeDateNoSeparator = /^\d{4,8}$/
|
|
const metric = (candidate: DateMatch) =>
|
|
Math.abs(candidate.year - REFERENCE_YEAR)
|
|
// # dates without separators are between length 4 '1191' and 8 '11111991'
|
|
for (let i = 0; i <= Math.abs(password.length - 4); i += 1) {
|
|
for (let j = i + 3; j <= i + 7; j += 1) {
|
|
if (j >= password.length) {
|
|
break
|
|
}
|
|
const token = password.slice(i, j + 1 || 9e9)
|
|
if (maybeDateNoSeparator.exec(token)) {
|
|
const candidates: any[] = []
|
|
const index = token.length
|
|
const splittedDates = DATE_SPLITS[index as keyof typeof DATE_SPLITS]
|
|
splittedDates.forEach(([k, l]) => {
|
|
const dmy = this.mapIntegersToDayMonthYear([
|
|
parseInt(token.slice(0, k), 10),
|
|
parseInt(token.slice(k, l), 10),
|
|
parseInt(token.slice(l), 10),
|
|
])
|
|
if (dmy != null) {
|
|
candidates.push(dmy)
|
|
}
|
|
})
|
|
if (candidates.length > 0) {
|
|
/*
|
|
* at this point: different possible dmy mappings for the same i,j substring.
|
|
* match the candidate date that likely takes the fewest guesses: a year closest
|
|
* to 2000.
|
|
* (scoring.REFERENCE_YEAR).
|
|
*
|
|
* ie, considering '111504', prefer 11-15-04 to 1-1-1504
|
|
* (interpreting '04' as 2004)
|
|
*/
|
|
let bestCandidate = candidates[0]
|
|
let minDistance = metric(candidates[0])
|
|
candidates.slice(1).forEach((candidate) => {
|
|
const distance = metric(candidate)
|
|
if (distance < minDistance) {
|
|
bestCandidate = candidate
|
|
minDistance = distance
|
|
}
|
|
})
|
|
matches.push({
|
|
pattern: 'date',
|
|
token,
|
|
i,
|
|
j,
|
|
separator: '',
|
|
year: bestCandidate.year,
|
|
month: bestCandidate.month,
|
|
day: bestCandidate.day,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return matches
|
|
}
|
|
|
|
/*
|
|
* matches now contains all valid date strings in a way that is tricky to capture
|
|
* with regexes only. while thorough, it will contain some unintuitive noise:
|
|
*
|
|
* '2015_06_04', in addition to matching 2015_06_04, will also contain
|
|
* 5(!) other date matches: 15_06_04, 5_06_04, ..., even 2015 (matched as 5/1/2020)
|
|
*
|
|
* to reduce noise, remove date matches that are strict substrings of others
|
|
*/
|
|
filterNoise(matches: DateMatch[]) {
|
|
return matches.filter((match) => {
|
|
let isSubmatch = false
|
|
const matchesLength = matches.length
|
|
for (let o = 0; o < matchesLength; o += 1) {
|
|
const otherMatch = matches[o]
|
|
if (match !== otherMatch) {
|
|
if (otherMatch.i <= match.i && otherMatch.j >= match.j) {
|
|
isSubmatch = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
return !isSubmatch
|
|
})
|
|
}
|
|
|
|
/*
|
|
* given a 3-tuple, discard if:
|
|
* middle int is over 31 (for all dmy formats, years are never allowed in the middle)
|
|
* middle int is zero
|
|
* any int is over the max allowable year
|
|
* any int is over two digits but under the min allowable year
|
|
* 2 integers are over 31, the max allowable day
|
|
* 2 integers are zero
|
|
* all integers are over 12, the max allowable month
|
|
*/
|
|
// eslint-disable-next-line complexity, max-statements
|
|
mapIntegersToDayMonthYear(integers: number[]) {
|
|
if (integers[1] > 31 || integers[1] <= 0) {
|
|
return null
|
|
}
|
|
let over12 = 0
|
|
let over31 = 0
|
|
let under1 = 0
|
|
for (let o = 0, len1 = integers.length; o < len1; o += 1) {
|
|
const int = integers[o]
|
|
if ((int > 99 && int < DATE_MIN_YEAR) || int > DATE_MAX_YEAR) {
|
|
return null
|
|
}
|
|
if (int > 31) {
|
|
over31 += 1
|
|
}
|
|
if (int > 12) {
|
|
over12 += 1
|
|
}
|
|
if (int <= 0) {
|
|
under1 += 1
|
|
}
|
|
}
|
|
if (over31 >= 2 || over12 === 3 || under1 >= 2) {
|
|
return null
|
|
}
|
|
return this.getDayMonth(integers)
|
|
}
|
|
|
|
// eslint-disable-next-line max-statements
|
|
getDayMonth(integers: number[]) {
|
|
// first look for a four digit year: yyyy + daymonth or daymonth + yyyy
|
|
const possibleYearSplits: [number, number[]][] = [
|
|
[integers[2], integers.slice(0, 2)], // year last
|
|
[integers[0], integers.slice(1, 3)], // year first
|
|
]
|
|
const possibleYearSplitsLength = possibleYearSplits.length
|
|
for (let j = 0; j < possibleYearSplitsLength; j += 1) {
|
|
const [y, rest] = possibleYearSplits[j]
|
|
if (DATE_MIN_YEAR <= y && y <= DATE_MAX_YEAR) {
|
|
const dm = this.mapIntegersToDayMonth(rest)
|
|
if (dm != null) {
|
|
return {
|
|
year: y,
|
|
month: dm.month,
|
|
day: dm.day,
|
|
}
|
|
}
|
|
/*
|
|
* for a candidate that includes a four-digit year,
|
|
* when the remaining integers don't match to a day and month,
|
|
* it is not a date.
|
|
*/
|
|
return null
|
|
}
|
|
}
|
|
// given no four-digit year, two digit years are the most flexible int to match, so
|
|
// try to parse a day-month out of integers[0..1] or integers[1..0]
|
|
for (let k = 0; k < possibleYearSplitsLength; k += 1) {
|
|
const [y, rest] = possibleYearSplits[k]
|
|
const dm = this.mapIntegersToDayMonth(rest)
|
|
if (dm != null) {
|
|
return {
|
|
year: this.twoToFourDigitYear(y),
|
|
month: dm.month,
|
|
day: dm.day,
|
|
}
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
mapIntegersToDayMonth(integers: number[]) {
|
|
const temp = [integers, integers.slice().reverse()]
|
|
// eslint-disable-next-line @typescript-eslint/prefer-for-of
|
|
for (let i = 0; i < temp.length; i += 1) {
|
|
const data = temp[i]
|
|
const day = data[0]
|
|
const month = data[1]
|
|
if (day >= 1 && day <= 31 && month >= 1 && month <= 12) {
|
|
return {
|
|
day,
|
|
month,
|
|
}
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
twoToFourDigitYear(year: number) {
|
|
if (year > 99) {
|
|
return year
|
|
}
|
|
if (year > 50) {
|
|
// 87 -> 1987
|
|
return year + 1900
|
|
}
|
|
// 15 -> 2015
|
|
return year + 2000
|
|
}
|
|
}
|
|
|
|
export default MatchDate
|