Перейти к содержимому страницы
Открыть меню
Переключить меню настроек
Открыть персональное меню
Вы не представились системе
Ваш IP-адрес будет виден всем, если вы внесёте какие-либо изменения.

Модуль:Math/tonumber

Материал из вики «Вече»

Для документации этого модуля может быть создана страница Модуль:Math/tonumber/doc

--[[
	Модуль для преобразования строк в числа и формирования категорий
	Поддерживает: годы, десятилетия, века, тысячелетия (включая «до н. э.»)
]]

local p = {}

local U, T = mw.ustring, mw.text
local floor, ceil, abs = math.floor, math.ceil, math.abs

-------------------------------------------------------------------------------
-- КОНСТАНТЫ
-------------------------------------------------------------------------------

local ERR_DATE = 'Категория:Википедия:Ошибки распознавания даты в Math/tonumber'

local ROMAN = {
	toArabic = {I = 1, V = 5, X = 10, L = 50, C = 100, D = 500, M = 1000},
	fromArabic = {
		{1000, 'M'}, {900, 'CM'}, {500, 'D'}, {400, 'CD'},
		{100, 'C'}, {90, 'XC'}, {50, 'L'}, {40, 'XL'},
		{10, 'X'}, {9, 'IX'}, {5, 'V'}, {4, 'IV'}, {1, 'I'}
	}
}

local BC_PATTERN = 'до%s*н%.?%s*э'

-------------------------------------------------------------------------------
-- СЛОВА О ВРЕМЕНИ
-------------------------------------------------------------------------------

local TIME_WORDS = {
	{'год[%a]*'}, {'гг?', dot = true}, {'лет'}, {'десятилет[%a]*'},
	{'век[%a]*'}, {'вв?', dot = true},
	{'тысячелет[%a]*'}, {'тыс', dot = true},
	{'до%s*н%.?%s*э%.?'}, {'н%.?%s*э%.?'},
}

local TIME_AFTER = {}
for _, w in ipairs(TIME_WORDS) do
	TIME_AFTER[#TIME_AFTER + 1] = '^%s*' .. w[1] .. '%.?'
end
for _, pat in ipairs({'^%s*или[%s%p]', '^%s*and[%s%p]', '^%s*or[%s%p]'}) do
	TIME_AFTER[#TIME_AFTER + 1] = pat
end

-------------------------------------------------------------------------------
-- МЕСЯЦЫ И КЛЮЧЕВЫЕ СЛОВА
-------------------------------------------------------------------------------

local MONTHS = {
	'январ[яье]', 'феврал[яье]', 'март[ае]?', 'апрел[яье]',
	'ма[йяе]', 'июн[яье]', 'июл[яье]', 'август[ае]?',
	'сентябр[яье]', 'октябр[яье]', 'ноябр[яье]', 'декабр[яье]'
}

local CANCEL_KEYWORDS = {
	'не%s*состоял', 'не%s*выпу', 'отменён', 'отменен',
	'невышедш', 'неиздан', 'заморожен', 'законсервир',
	'не%s*начат', 'остановлен', 'не%s*был',
	'не%s*достроен', 'не%s*завершён', 'не%s*завершен'
}

local PLANNED_KEYWORDS = {
	'^tba$', '^%{%{%s*н%.?%s*в%.?%s*%}%}$',
	'^н%.?%s*в%.?$', '^наст%.?%s*время$',
	'запланирован', 'строится'
}

local MILLENNIA_AGO_PATTERN = 'тыс%.?%s*лет%s*назад'

-------------------------------------------------------------------------------
-- ПАТТЕРНЫ ДЛЯ УДАЛЕНИЯ (шум)
-------------------------------------------------------------------------------

local NOISE = {
	-- HTML
	{pat = '<sup.-</sup>', use = 'gsub'},
	{pat = '<ref.-</ref>', use = 'gsub'},
	{pat = '<ref[^/]*/?>',  use = 'gsub'},
	{pat = '<br.->', repl = '\n', use = 'gsub'},
	{pat = '<span[^>]-display%s*:%s*none[^>]*>.-</span>', use = 'gsub'},
	{pat = '<[^<>]+>', use = 'gsub'},
	-- Категории
	{pat = '%[%[[Cc]ategory:.-%]%]', use = 'gsub'},
	{pat = '%[%[[Кк]атегория:.-%]%]', use = 'ugsub'},
	-- Возрасты
	{pat = '[;,]%s*%d+%s+[гл][ое][дт][%a]* назад', use = 'ugsub'},
	{pat = '%(%d+[^%)]*[гл][ое][дт][^%)]*%)', use = 'ugsub'},
	-- Идентификаторы в скобках
	{pat = '%([^%)]*[A-Za-zА-Яа-яЁё][^%)]*[%-%/][^%)]*%d+[^%)]*%)', use = 'ugsub'},
	{pat = '%(%d+%-й[^%)]*%)', use = 'ugsub'},
	{pat = '%(as [^%)]+%)', use = 'ugsub'},
	{pat = '%(как [^%)]+%)', use = 'ugsub'},
	-- Временные уточнения
	{pat = '[нН]ачал[оа]%s+', use = 'ugsub'},
	{pat = '[сС]ередин[ауые]%s+', use = 'ugsub'},
	{pat = '[кК]он[ец][цчае]?%s+', use = 'ugsub'},
	-- Номера
	{pat = '№%s*%d+', use = 'ugsub'},
}

-------------------------------------------------------------------------------
-- ПАТТЕРНЫ ДЛЯ СКАНИРОВАНИЯ
-------------------------------------------------------------------------------

local MILL_BASE = '(%d+)%s*%-?%s*[%aА-Яа-яЁё]*%s*тыс%.?'

local SCAN = {
	-- Тысячелетия
	{t = 'millennium', p = '(%d+)%-?[его]*%s+тысячелет'},
	{t = 'millennium', p = MILL_BASE .. '%s*до%s*н%.?%s*э%.?', bc = true},
	{t = 'millennium', p = MILL_BASE .. '%s*н%.?%s*э%.?', bc = false},
	{t = 'millennium', p = MILL_BASE .. '%s*$'},
	-- Века (римские)
	{t = 'century', p = '([IVXLCDM]+)%s*вв?%.?е?к?[ауе]?', f = 'roman', ctx = true},
	{t = 'century', p = '([IVXLCDM]+)%s*[%-%–—]%s*[IVXLCDM]+', f = 'roman'},
	{t = 'century', p = '[%s%[(]([IVXLCDM]+)[%s%])]', f = 'roman'},
	{t = 'century', p = '^%s*([IVXLCDM]+)%s*$', f = 'roman'},
	{t = 'century', p = '%d%-[яй]%s+пол[ао]?в?и?н?[аa]?%.?%s*([IVXLCDM]+)', f = 'roman'},
	-- Века (арабские)
	{t = 'century', p = '(%d+)%s*век[ауе]?%s*до%s*н%.?%s*э', bc = true, ctx = true},
	{t = 'century', p = '(%d+)%s*вв?%.%s*до%s*н%.?%s*э', bc = true, ctx = true},
	{t = 'century', p = '(%d+)%s*век[ауе]?', ctx = true},
	{t = 'century', p = '(%d+)%s*вв?%.', ctx = true},
	-- Десятилетия
	{t = 'decade', p = '(%d+0)%-?[ехxeЕХE]'},
	{t = 'decade', p = '[%s%[(](0)%-?[ехxeЕХE]'},
	{t = 'decade', p = '^(0)%-?[ехxeЕХE]'},
	-- Годы с эпохой
	{t = 'year', p = '(%d+)%s+лет%s+до%s*н%.?%s*э%.?', bc = true},
	{t = 'year', p = '(%d+)%s+г?%.?%s*до%sн%.%s?э', bc = true},
	{t = 'year', p = '(%d+)%s*до%s*н%s+э', bc = true},
	{t = 'year', p = '(%d+)%s*донэ', bc = true},
	{t = 'year', p = '(%d+)%s+г?%.?%s*н%.%s?э', bc = false},
	{t = 'year', p = '(%d+)%s+н%s*э', bc = false},
	{t = 'year', p = '(%d+)%s*нэ', bc = false},
	-- Годы с контекстом
	{t = 'year', p = '(%d+) лет назад', rel = true},
	{t = 'year', p = '(%d+)%-?[ймго]%.?%s*[гл][ое][дт]'},
	{t = 'year', p = '(%d+)%s+год[ауеом]?'},
	{t = 'year', p = '(%d+)%s+г%.'},
	-- Форматы дат
	{t = 'year', p = '%d%d?%.%d%d?%.(%d%d%d%d)'},
	{t = 'year', p = '%d%d?%.%d%d?%.(%d%d%d)'},
	{t = 'year', p = '%d%d?%.%d%d?%.(%d%d)[^%d]'},
	{t = 'year', p = '(%d%d%d%d)%.%d%d?%.%d%d?'},
	{t = 'year', p = '%d%d?%.(%d%d%d%d)'},
	{t = 'year', p = '(%d%d%d%d)%-%d%d%-%d%d'},
	{t = 'year', p = '(%d%d%d%d?)%-[01]%d[^%d]'},
	{t = 'year', p = '(%d%d%d%d?)%-[01]%d$'},
	-- Числа в контексте
	{t = 'year', p = '[%s%(%+%-;%[/~,](%d%d%d%d)%D'},
	{t = 'year', p = '[%s%(%+%-;%[/~,](%d%d%d)%D'},
	{t = 'year', p = '[%s%(%+%-;%[/~,](%d%d%d%d)$'},
	{t = 'year', p = '[%s%(%+%-;%[/~,](%d%d%d)$'},
	{t = 'year', p = '^(%d%d%d%d)%D'},
	{t = 'year', p = '^(%d%d%d)%D'},
	{t = 'year', p = '%(%s*(%d%d%d%d)%s*%)'},
	{t = 'year', p = '[%s;,](%d%d)%s*%('},
	-- 2-значные годы с явным контекстом
	{t = 'year', p = '%]%]%s*(%d%d)%s'},
	{t = 'year', p = '%]%]%s*(%d%d)%)'},
	{t = 'year', p = '%]%]%s*(%d%d)$'},
	{t = 'year', p = '[%s;,](%d%d)%s+год[ауеом]?'},
	{t = 'year', p = '[%s;,](%d%d)%s+г%.'},
	{t = 'year', p = '[%s;,](%d%d)%s*%)'},
	{t = 'year', p = '[%s;,](%d%d) $'},
	{t = 'year', p = '^ (%d%d)[%;%s]'},
	{t = 'year', p = '^ (%d%d) $'},
}

-------------------------------------------------------------------------------
-- РИМСКИЕ ЦИФРЫ
-------------------------------------------------------------------------------

local function romanToArabic(s)
	if not s or s == '' then return nil end
	s = s:upper()
	local result, prev = 0, 0
	for i = #s, 1, -1 do
		local v = ROMAN.toArabic[s:sub(i, i)]
		if not v then return nil end
		result = result + (v < prev and -v or v)
		prev = v
	end
	return result
end

local function arabicToRoman(n)
	if not n or n <= 0 then return nil end
	local result = ''
	for _, pair in ipairs(ROMAN.fromArabic) do
		while n >= pair[1] do
			result = result .. pair[2]
			n = n - pair[1]
		end
	end
	return result
end

-------------------------------------------------------------------------------
-- УТИЛИТЫ
-------------------------------------------------------------------------------

local function normDash(s)
	return U.gsub(s, '[—–‑−]', '-')
end

local function stripWiki(s)
	s = U.gsub(s, '%[%[[^%]|]*|([^%]]*)%]%]', '%1')
	return U.gsub(s, '%[%[([^%]]*)%]%]', '%1')
end
local function stripHtml(s)
	return U.gsub(s or '', '<[^<>]+>', '')
end

local function hasBC(s)
	return U.match(U.lower(s), BC_PATTERN) ~= nil
end

local function isTruthy(v)
	if not v then return false end
	local s = T.trim(v):lower()
	return s == '1' or s == 'yes'
end

local function matchesAny(s, patterns)
	for _, pattern in ipairs(patterns) do
		if U.match(s, pattern) then return true end
	end
	return false
end

local function dateErrCat(t)
	return (t and t ~= '') and (ERR_DATE .. ' (' .. t .. ')') or ERR_DATE
end

local function signedVal(r)
	if r.bc then return r.value == 0 and '-0' or -r.value end
	return r.value
end

local function adjustPrep(title, n)
	if n == 2 and U.match(title, '%sв$') then
		return U.gsub(title, '%sв$', ' во')
	end
	return title
end

local function isTimeWord(after)
	local chunk = U.sub(U.lower(after), 1, 15) or ''
	for _, pat in ipairs(TIME_AFTER) do
		if U.match(chunk, pat) then return true end
	end
	return false
end

local function hasMonthContext(s)
	local lower = U.lower(s or '')
	for _, m in ipairs(MONTHS) do
		if U.match(lower, m .. '%s*$') or U.match(lower, m .. '%s+%d%d?[,;]%s*$') then
			return true
		end
	end
	return false
end

-------------------------------------------------------------------------------
-- ФОРМАТИРОВАНИЕ
-------------------------------------------------------------------------------

local FMT = {
	year = {
		cat = {
			function(v, bc) return v .. ' году' .. (bc and ' до н. э.' or '') end,
			function(v, bc) return v .. ' года' .. (bc and ' до н. э.' or '') end,
		},
		str = {
			short = function(v, bc) return tostring(v) .. (bc and ' до н. э.' or '') end,
			i = function(v, bc) return tostring(v) .. ' год' .. (bc and ' до н. э.' or '') end,
			r = function(v, bc) return tostring(v) .. ' года' .. (bc and ' до н. э.' or '') end,
			p = function(v, bc) return 'в ' .. tostring(v) .. ' году' .. (bc and ' до н. э.' or '') end,
		},
	},
	decade = {
		cat = {
			function(v, bc) return v .. '-е годы' .. (bc and ' до н. э.' or '') end,
			function(v, bc) return v .. '-х годов' .. (bc and ' до н. э.' or '') end,
		},
		str = {
			short = function(v, bc) return v .. '-е' .. (bc and ' до н. э.' or '') end,
			i = function(v, bc) return v .. '-е годы' .. (bc and ' до н. э.' or '') end,
			r = function(v, bc) return v .. '-х годов' .. (bc and ' до н. э.' or '') end,
			p = function(v, bc) return 'в ' .. v .. '-х годах' .. (bc and ' до н. э.' or '') end,
		},
	},
	century = {
		cat = {
			function(v, bc) return arabicToRoman(v) .. ' веке' .. (bc and ' до н. э.' or '') end,
			function(v, bc) return arabicToRoman(v) .. ' века' .. (bc and ' до н. э.' or '') end,
		},
		str = {
			short = function(v, bc) return arabicToRoman(v) .. (bc and ' до н. э.' or '') end,
			i = function(v, bc) return arabicToRoman(v) .. ' век' .. (bc and ' до н. э.' or '') end,
			r = function(v, bc) return arabicToRoman(v) .. ' века' .. (bc and ' до н. э.' or '') end,
			p = function(v, bc) 
				local prep = v == 2 and 'во ' or 'в '
				return prep .. arabicToRoman(v) .. ' веке' .. (bc and ' до н. э.' or '') 
			end,
		},
	},
	millennium = {
		cat = {
			function(v, bc) return v .. '-м тысячелетии' .. (bc and ' до н. э.' or '') end,
			function(v, bc) return v .. '-го тысячелетия' .. (bc and ' до н. э.' or '') end,
		},
		str = {
			short = function(v, bc) return v .. '-е' .. (bc and ' до н. э.' or '') end,
			i = function(v, bc) return v .. '-е тысячелетие' .. (bc and ' до н. э.' or '') end,
			r = function(v, bc) return v .. '-го тысячелетия' .. (bc and ' до н. э.' or '') end,
			p = function(v, bc) 
				local prep = v == 2 and 'во ' or 'в '
				return prep .. v .. '-м тысячелетии' .. (bc and ' до н. э.' or '') 
			end,
		},
	},
}

local YEAR_FMT = {
	['годы']  = function(y) return floor(y / 10) .. '0-е' end,
	['годов'] = function(y) return floor(y / 10) .. '0-х' end,
	['века']  = function(y) return arabicToRoman(ceil(y / 100)) end,
	['тысячелетия'] = function(y) return ceil(y / 1000) .. '-го' end,
}
-------------------------------------------------------------------------------
-- ОЧИСТКА ВХОДА
-------------------------------------------------------------------------------

local function cleanInput(s)
	if not s then return nil, false end
	
	s = T.unstrip(s)
	
	-- Нормализация
	s = U.gsub(s, '&#(%d+);', function(n) return U.char(tonumber(n)) end)
	s = U.gsub(s, '&nbsp;', ' '):gsub('&thinsp;', ' ')
		:gsub('&lt;', '<'):gsub('&gt;', '>'):gsub('&amp;', '&')
	s = U.gsub(s, ' ', ' ')
	s = normDash(s)
	
	-- Удаление шума
	for _, n in ipairs(NOISE) do
		if n.use == 'gsub' then s = s:gsub(n.pat, n.repl or '')
		else s = U.gsub(s, n.pat, n.repl or '') end
	end
	
	-- Проверка месяца до удаления ссылок
	local lower = U.lower(s)
	local hasMonth = false
	for _, m in ipairs(MONTHS) do
		if U.match(lower, m) then hasMonth = true break end
	end
	
	s = stripWiki(s)
	s = s:gsub('(%d) (%d%d%d)', '%1%2')
	
	local trimmed = T.trim(s)
	if trimmed == '' or trimmed == '-' then return nil, false end
	
	return trimmed, hasMonth
end

-------------------------------------------------------------------------------
-- ПАРСЕР: СКАНИРОВАНИЕ
-------------------------------------------------------------------------------

local function scan(padded, out, spec, bcGlobal)
	local init = 1
	local valFunc = spec.f == 'roman' and romanToArabic or nil
	
	while true do
		local si, ei, c1 = U.find(padded, spec.p, init)
		if not si then break end
		
		local val = valFunc and valFunc(c1) or tonumber(c1)
		if val then
			local valStr = c1 or tostring(abs(val))
			local numPos = U.find(padded, valStr, si, true)
			local numEnd = numPos and (numPos + U.len(valStr) - 1) or ei
			
			local bc = spec.bc
			if bc == nil then bc = bcGlobal and U.match(padded, '%s*до%s*н%.?%s*э', ei) ~= nil or false end
			
			out[#out + 1] = {
				type = spec.t, value = abs(val), pos = numPos or si, endPos = numEnd,
				matchEnd = ei, bc = bc, rel = spec.rel, short = spec.short,
				rangeSecond = spec.rangeSecond, ctx = spec.ctx
			}
		end
		init = si + 1
	end
end

-------------------------------------------------------------------------------
-- ПАРСЕР: КЛАССИФИКАЦИЯ ГОДА
-------------------------------------------------------------------------------

local function classifyYear(c, padded, hasMonth)
	local before = U.sub(padded, c.pos > 10 and c.pos - 10 or 1, c.pos - 1) or ''
	local after = U.sub(padded, c.endPos + 1, c.endPos + 20) or ''
	local afterL = U.lower(after)
	
	-- Явный год
	if c.bc or U.match(before, '[%.%-]%d+$') or U.match(after, '^[%.%-]%d')
		or U.match(afterL, '^%s*гг?%.')
		or U.match(afterL, '^%s*г[оа]?[дуеом]?[%s%p]') or U.match(afterL, '^%s*г[оа]?[дуеом]?$')
		or U.match(afterL, '^%s*н%.%s*э') or U.match(afterL, '^%s*до%s*н%.%s*э')
		or (c.value < 100 and U.match(before, '%]%]%s*$'))
		or hasMonthContext(before) then
		return true, false, false
	end
	
	-- Исключения
	if U.match(after, '^[,.]%d') then return false, false, true end
	if U.match(afterL, '^%s*тыс') then return false, false, true end
	if U.match(afterL, '^%s*вв?%.') or U.match(afterL, '^%s*век') then return false, false, true end
	if hasMonth and c.value < 1000 and U.match(before, '%(%s*$') then return false, false, true end
	if c.value < 100 and U.match(before, '[A-Za-zА-Яа-яЁё][%-/]$') then return false, false, true end
	if U.match(after, '^%s*:') and U.match(before, '[A-Za-zА-Яа-яЁё]%s*$') then return false, false, true end
	if U.match(afterL, '^%-[ойыегмх][йуюа]?%s+[^гвд]') then return false, false, true end
	if hasMonth and c.value <= 31 and not U.match(afterL, '^%s*г[оа]?[дуеом]?') then return false, false, true end
	if c.value % 10 == 0 and U.match(afterL, '^%s*[-‑–—]?[ехx]') and not U.match(afterL, '^%s*[-‑–—]?[ivxlcdm]+%s*в') then
		return false, false, true
	end
	
	-- Слабый кандидат
	if not U.find(after, '\n', 1, true) and not isTimeWord(after) then
		-- короткое слово (1–2 буквы) после числа — вероятно единица, не год
		-- исключение: за ним сразу число (диапазон типа "по 2000")
		local word = U.match(afterL, '^%s*([%aА-Яа-яЁё]+)')
		if word and U.len(word) <= 2 and not U.match(afterL, '^%s*[%aА-Яа-яЁё]+%.?%s+%d') then
			return false, true, false
		end
		if U.match(afterL, '^%s*[%aА-Яа-яЁё][%aА-Яа-яЁё][%aА-Яа-яЁё]') then return false, true, false end
	end
	
	return false, false, false
end

-------------------------------------------------------------------------------
-- ПАРСЕР: ФИЛЬТРАЦИЯ
-------------------------------------------------------------------------------

local function filterCandidates(candidates, hasMonth, padded)
	table.sort(candidates, function(a, b)
		return a.pos < b.pos or (a.pos == b.pos and a.endPos > b.endPos)
	end)
	
	local filtered, hasStrong = {}, false
	for _, c in ipairs(candidates) do
		if c.type == 'year' then c.explicit, c.weak, c.invalid = classifyYear(c, padded, hasMonth) end
		
		if not (c.type == 'year' and c.invalid) then
			local dominated = false
			for _, a in ipairs(filtered) do
				if c.pos >= a.pos and c.endPos <= a.endPos then dominated = true break end
			end
			if not dominated then
				filtered[#filtered + 1] = c
				if c.type ~= 'year' or not c.weak then hasStrong = true end
			end
		end
	end
	
	-- Убираем слабые если есть сильные
	if hasStrong then
		local strong = {}
		for _, c in ipairs(filtered) do
			if c.type ~= 'year' or not c.weak then strong[#strong + 1] = c end
		end
		filtered = strong
	end
	
	-- Если есть век с явным контекстом, отбрасываем прочие римские числа
	local hasCtx = false
	for _, c in ipairs(filtered) do
		if c.type == 'century' and c.ctx then hasCtx = true break end
	end
	if hasCtx then
		local tmp = {}
		for _, c in ipairs(filtered) do
			if c.type ~= 'century' or c.ctx then tmp[#tmp + 1] = c end
		end
		filtered = tmp
	end
	
	return filtered
end

local function applyPrefer(filtered, prefer)
	if not prefer or #filtered == 0 then return filtered end
	
	local pref = {}
	for _, c in ipairs(filtered) do
		if prefer == 'year_or_decade' then
			if c.type == 'year' or c.type == 'decade' then pref[#pref + 1] = c end
		elseif c.type == prefer then
			pref[#pref + 1] = c
		end
	end
	return #pref > 0 and pref or filtered
end

local function fixBCRanges(filtered, padded)
	for i = 1, #filtered - 1 do
		local c1, c2 = filtered[i], filtered[i + 1]
		if c1.type == 'year' and c2.type == 'year' and c2.bc and not c1.bc then
			local sep = U.sub(padded, c1.endPos, c2.pos - 1)
			if U.match(sep, '^%s*[—–%-]%s*$') then c1.bc = true end
		end
	end
end

-------------------------------------------------------------------------------
-- ПРОВЕРКИ КАЧЕСТВА
-------------------------------------------------------------------------------

local function findLastPos(s, pattern, plain)
	local pos, start = nil, 1
	while true do
		local f = U.find(s, pattern, start, plain)
		if not f then break end
		pos, start = f, f + 1
	end
	return pos
end

local function getSuspicionLevel(orig, searchStr)
	if not searchStr then return false end
	
	local s = T.unstrip(orig)
	local origStr, yearStr = tostring(searchStr), U.lower(tostring(searchStr))
	
	local clean = s:gsub('<ref.-</ref>', ''):gsub('<ref[^>]*/>', '')
		:gsub('{{efn.-}}', ''):gsub('{{sfn.-}}', ''):gsub('{{примечание.-}}', '')
		:gsub('{{note.-}}', ''):gsub('{{комментарий.-}}', ''):gsub('<sup.-</sup>', ''):gsub('%b()', '')
	clean = stripHtml(clean)
	
	local norm = normDash(U.lower(clean))
	local pos = findLastPos(norm, yearStr, true)
	if not pos then return false end
	
	local after = U.sub(norm, pos + #yearStr, pos + #yearStr + 20)
	if not after or after == '' then return false end
	
	local rawPos = findLastPos(orig, origStr, true)
	if rawPos then
		local ch = U.match(U.sub(orig, rawPos + #origStr, rawPos + #origStr + 10), '^%s*(.)')
		if ch == '{' or ch == '<' or ch == '\127' then return false end
	end
	
	local trimmed = U.match(after, '^%s*(.*)') or ''
	if trimmed == '' or isTimeWord(after) then return false end
	if U.match(trimmed, '^<') or U.match(after, '^%-[ехx]') or U.match(after, '^[,%)%]%-/<>%[|{}]') then return false end
	if U.match(trimmed, '^%d') or U.match(U.sub(trimmed, 1, 10), '%d') then return false end
	
	local word = U.match(trimmed, '^([%a]+)')
	if not word or word == '' then return false end
	return U.len(word) <= 2 and 'soft' or 'hard'
end

local function getDateIssues(s)
	local norm = normDash(stripWiki(stripHtml(T.unstrip(s or ''))))
	local lower = U.lower(norm)
	local isBC = U.match(lower, BC_PATTERN) or U.match(lower, 'до%s*нашей%s*эры')
	
	if not isBC then
		local y, yy, after = U.match(norm, '(%d%d%d%d?)%-([1-9]%d)(%D)')
		if not y then y, yy = U.match(norm, '(%d%d%d%d?)%-([1-9]%d)$') end
		if y and yy and tonumber(yy) >= 13 and tonumber(yy) <= 99 and (after or '') ~= '-' then return true end
		local y1, y2 = U.match(norm, '(%d%d%d%d?)%-(%d%d%d%d?)')
		if y1 and y2 and tonumber(y2) < tonumber(y1) then return true end
	end
	
	return U.match(lower, 'до%s*н%s+э') or U.match(lower, 'донэ')
		or (U.match(lower, '%d%s*нэ') and not U.match(lower, 'донэ'))
		or U.match(lower, '%d%s+н%s+э') or U.match(norm, '%d%d%d%d%.%d%d?%.%d%d?')
		or U.match(norm, '%d%d%d%d?,%d%d%d%d?')
end

local function isDecadeNoHyphen(s)
	return U.match(s, '%d0[ехxeЕХE]') and not U.match(s, '%d0[-‑–—][ехxeЕХE]')
end

local function hasAbbrevNoDot(s)
	local l = U.lower(stripHtml(T.unstrip(s or '')))
	return U.match(l, '[%divxlcdm]%s*гг?[%s,)%]]') or U.match(l, '%d%s*тыс[%s,)%]]')
		or U.match(l, '%d%s*тыс$') or U.match(l, '[ivxlcdm]%s*вв?[%s,)%]]')
		or U.match(l, '%d%s*в[%s,)%]]')
end

local function needsVerification(r, s)
	if r.type == 'decade' and isDecadeNoHyphen(s) then return true end
	if r.short or r.rel then return true end
	if r.type == 'year' and getSuspicionLevel(s, abs(r.value)) then return true end
	if r.type == 'century' and getSuspicionLevel(s, arabicToRoman(r.value)) then return true end
	return getDateIssues(s) or hasAbbrevNoDot(s)
end

local function getShortRangeYear(s)
	local norm = normDash(s)
	local lower = U.lower(norm)
	if not (U.match(lower, '%d%d%d%d%-[1-9]%d?%s*год') or U.match(lower, '%d%d%d%d%-[1-9]%d?%s*гг')) then return nil end
	return tonumber(U.match(norm, '(%d%d%d%d)%-[1-9]%d?'))
end

-------------------------------------------------------------------------------
-- ГЛАВНЫЙ ПАРСЕР
-------------------------------------------------------------------------------

-- Паттерны для сокращённых диапазонов (1927-28), исключая месяцы (01-12)
local SHORT_RANGE = {
	'%d%d%d%d%-(1[3-9])%D', '%d%d%d%d%-(1[3-9])$',
	'%d%d%d%d%-([2-9]%d)%D', '%d%d%d%d%-([2-9]%d)$',
}

local function parseTime(s, useFirst, prefer)
	local str, hasMonth = cleanInput(s)
	if str == nil then return {type = 'skip'} end
	if str == '' then return {type = 'error', errorType = 'неизвестное значение'} end
	
	local trimmed = T.trim(str)
	if trimmed == '' then return {type = 'skip'} end
	
	local bcGlobal = hasBC(str)
	
	-- Специальные значения
	local clean = U.lower(T.trim(str:gsub("['\"]+" , '')))
	if clean == 'значение отсутствует' then return {type = 'skip'} end
	if clean == '?' or clean == 'неизвестно' or clean == 'значение неизвестно' then
		return {type = 'error', errorType = 'неизвестное значение'}
	end
	if U.match(clean, MILLENNIA_AGO_PATTERN) then
		return {type = 'error', errorType = 'тысячелетия назад'}
	end
	if matchesAny(clean, CANCEL_KEYWORDS) then return {type = 'error', errorType = 'отменено'} end
	if matchesAny(clean, PLANNED_KEYWORDS) then return {type = 'error', errorType = 'планируется'} end
	
	-- Чистое число
	local numOnly = U.match(trimmed, '^%-?%d+$')
	if numOnly and not hasMonth then
		local val = tonumber(numOnly)
		if val then return {type = 'year', value = abs(val), bc = trimmed:sub(1, 1) == '-'} end
	end
	
	-- Число в скобках
	local parenOnly = U.match(trimmed, '^%(%s*(%d+)%s*%)$')
	if parenOnly then return {type = 'year', value = tonumber(parenOnly), bc = false} end
	
	-- Диапазон до н. э.
	local y1, y2 = U.match(str, '(%d+)%s*%-%s*(%d+)%s+до%s*н')
	if y1 and y2 then
		return {type = 'year', value = tonumber(useFirst and y1 or y2), bc = true, rangeSecond = not useFirst}
	end
	
	local function fallback()
		local hasYearWord = U.match(U.lower(str), '%d+%s*год')
		local hasYearNum = str:match('%d%d%d%d') or str:match('%d%d%d$') or str:match('%d%d%d[^%d]')
		if hasMonth and not hasYearWord and not hasYearNum then
			return {type = 'error', errorType = 'дата без года'}
		end
		if U.match(str, 'век') or U.match(str, 'тысяч') then return {type = 'skip'} end
		return {type = 'notfound'}
	end
	
	local padded = ' ' .. str .. ' '
	local candidates = {}
	
	-- Особый случай: "18 до н. э. / 234"
	if U.match(str, 'до%s*н%.?%s*э%.?.-/') then
		local n = U.match(str, 'до%s*н%.?%s*э%.?.-/%s*(%d+)')
		if n then candidates[#candidates + 1] = {type = 'year', value = tonumber(n), pos = 9999, endPos = 9999, bc = false} end
	end
	
	-- Сканирование
	for _, spec in ipairs(SCAN) do scan(padded, candidates, spec, bcGlobal) end
	
	if bcGlobal and not useFirst then
		scan(padded, candidates, {t = 'year', p = '%d+%-%s*(%d+)%s+до%s*н', bc = true, rangeSecond = true}, false)
	end
	if not useFirst then
		for _, pat in ipairs(SHORT_RANGE) do
			scan(padded, candidates, {t = 'year', p = pat, short = true}, bcGlobal)
		end
	end
	
	-- Целая строка = число
	local fullNum = U.match(trimmed, '^%-?(%d+)$')
	if fullNum then
		candidates[#candidates + 1] = {type = 'year', value = tonumber(fullNum), pos = 1, endPos = #str, bc = trimmed:match('^%-') ~= nil}
	end
	
	if #candidates == 0 then return fallback() end
	
	local filtered = filterCandidates(candidates, hasMonth, padded)
	if #filtered == 0 then return fallback() end
	
	filtered = applyPrefer(filtered, prefer)
	if #filtered == 0 then return fallback() end
	
	fixBCRanges(filtered, padded)
	
	-- Приоритет: 4-значные над короткими, явные над слабыми
	if not useFirst and #filtered > 1 then
		local last = filtered[#filtered]
		if last.type == 'year' then
			local dominated = (last.value < 100 and not last.rangeSecond and not last.short) or last.weak
			if dominated then
				for i = #filtered - 1, 1, -1 do
					local c = filtered[i]
					if c.type == 'year' and ((last.value < 100 and c.value >= 100) or (last.weak and (c.explicit or c.value >= 100))) then
						filtered[#filtered] = c
						break
					end
				end
			end
		end
	end
	
	-- Сокращённые диапазоны
	local shortYear = getShortRangeYear(str)
	if shortYear and not useFirst then
		for _, c in ipairs(filtered) do
			if c.type == 'year' and c.value == shortYear then
				return {type = c.type, value = c.value, bc = c.bc, short = true}
			end
		end
	end
	
	local res = useFirst and filtered[1] or filtered[#filtered]
	return {type = res.type, value = res.value, bc = res.bc, rel = res.rel, short = res.short}
end

-------------------------------------------------------------------------------
-- ФОРМАТИРОВАНИЕ КАТЕГОРИЙ
-------------------------------------------------------------------------------

local function formatCat(result, title, form)
	if not result or result.value == nil then return dateErrCat() end
	
	local t, v, bc = result.type, result.value, result.bc
	if t ~= 'decade' then title = adjustPrep(title, v) end
	
	local fmts = FMT[t] and FMT[t].cat
	local fmt = fmts and (fmts[form or 1] or fmts[1])
	if not fmt then return dateErrCat() end
	
	return 'Категория:' .. title .. ' ' .. fmt(v, bc)
end

-------------------------------------------------------------------------------
-- ПУБЛИЧНОЕ API
-------------------------------------------------------------------------------

function p.integer(frame)
	local s = frame.args[1]
	if not s then return nil end
	s = s:gsub('<[^<>]+>', ''):gsub('[^0-9]', ' ')
	return tonumber(T.split(T.trim(s), ' ')[1])
end

function p.quantity(frame)
	local s = frame.args[1]
	if not s then return nil end
	s = s:gsub('<[^<>]+>', ''):gsub(' ', '')
		:gsub('тыс', '000'):gsub('млн', '000000'):gsub('млрд', '000000000')
		:gsub(',(%d%d%d)000', '%1'):gsub(',(%d%d)000', '%10'):gsub(',(%d)000', '%100')
		:gsub('[±].*$', ''):gsub('[^0-9]', ' ')
	return tonumber(T.split(T.trim(s), ' ')[1])
end

function p.yearCategory(frame)
	local year = tonumber(T.trim(frame.args[1] or ''))
	if not year then return dateErrCat() end
	
	local title = T.trim(frame.args[2] or '')
	local form = T.trim(frame.args[3] or '')
	if form == '' then form = 'году' end
	
	local absY = abs(year)
	title = adjustPrep(title, absY)
	if YEAR_FMT[form] then absY = YEAR_FMT[form](absY) end
	
	return 'Категория:' .. title .. ' ' .. absY .. ' ' .. form .. (year < 0 and ' до н. э.' or '')
end

function p.decadeCategory(frame)
	local raw = T.trim(frame.args[1] or '')
	local dec = tonumber(raw)
	if dec == nil and raw ~= '-0' then return dateErrCat() end
	
	local bc = raw == '-0' or (dec and dec < 0)
	local form = T.trim(frame.args[3] or '') == 'годов' and 2 or 1
	return formatCat({type = 'decade', value = dec and abs(dec) or 0, bc = bc}, T.trim(frame.args[2] or ''), form)
end

function p.centuryCategory(frame)
	local c = tonumber(T.trim(frame.args[1] or ''))
	if not c then return dateErrCat() end
	local form = T.trim(frame.args[3] or '') == 'века' and 2 or 1
	return formatCat({type = 'century', value = abs(c), bc = c < 0}, T.trim(frame.args[2] or ''), form)
end

function p.millenniumCategory(frame)
	local m = tonumber(T.trim(frame.args[1] or ''))
	if not m then return dateErrCat() end
	local form = T.trim(frame.args[3] or '') == 'тысячелетия' and 2 or 1
	return formatCat({type = 'millennium', value = abs(m), bc = m < 0}, T.trim(frame.args[2] or ''), form)
end

local function extract(frame, expected)
	local s = frame.args[1]
	if not s then return frame.args['default'] end
	local r = parseTime(s, false)
	if r.type ~= expected then return frame.args['default'] or '' end
	return signedVal(r)
end

function p.decade(frame) return extract(frame, 'decade') end
function p.century(frame) return extract(frame, 'century') end
function p.millennium(frame) return extract(frame, 'millennium') end

function p.datefromstring(frame)
	local s = frame.args[1]
	local default = frame.args['default']
	
	if not s or T.trim(s) == '' then s = default end
	if not s then return '' end
	
	local r = parseTime(s, isTruthy(frame.args['first']))
	
	if (r.type == 'skip' or r.type == 'error' or r.type == 'notfound') and default and s ~= default then
		r = parseTime(default, isTruthy(frame.args['first']))
	end
	
	if r.type == 'skip' or r.type == 'error' or r.type == 'notfound' then
		return default or ''
	end
	
	local fmts = FMT[r.type] and FMT[r.type].str
	local case = frame.args['case']
	local fmt = fmts and fmts[(case == 'i' or case == 'r' or case == 'p') and case or 'short']
	
	return fmt and fmt(r.value, r.bc) or default or ''
end

function p.year(frame)
	local s = frame.args[1]
	if not s then return frame.args['default'] end
	
	local r = parseTime(s, isTruthy(frame.args['first']), 'year_or_decade')
	
	if r.type == 'skip' then return frame.args['default'] or '' end
	if r.type == 'error' then return '' end
	if r.type == 'notfound' then return frame.args['default'] or '' end
	if r.type == 'year' or r.type == 'decade' then return signedVal(r) end
	return ''
end

function p.datecat(frame)
	local s = frame.args[1]
	if not s or T.trim(s) == '' then return '' end
	local raw = T.trim(s)
	if raw == '-' then return '' end
	if raw == '—' or raw == '–' then return '[[' .. dateErrCat('неизвестное значение') .. ']]' end
	
	local cat = frame.args['cat'] or ''
	local form = tonumber(frame.args['form']) or 1
	local cutD, cutC, cutM = tonumber(frame.args['cut-decade']), tonumber(frame.args['cut-century']), tonumber(frame.args['cut-mill'])
	local suffix = frame.args['suffix']
	suffix = suffix and T.trim(suffix) ~= '' and (' ' .. T.trim(suffix)) or ''
	
	local r = parseTime(s, isTruthy(frame.args['first']))
	
	if r.type == 'skip' then return '' end
	if r.type == 'error' then return '[[' .. dateErrCat(r.errorType) .. ']]' end
	if r.type == 'notfound' then return '[[' .. dateErrCat() .. ']]' end
	
	-- Автоукрупнение
	local function getCompareValue(tp, val, bc)
		local v
		if tp == 'year' then v = val
		elseif tp == 'decade' then v = val + 1
		elseif tp == 'century' then v = val * 100
		elseif tp == 'millennium' then v = val * 1000
		else return nil end
		return bc and -v or v
	end
	
	local sv = getCompareValue(r.type, r.value, r.bc)
	if sv then
		if cutM and sv < cutM and r.type ~= 'millennium' then
			if r.type == 'year' or r.type == 'decade' then
				r.value = ceil(r.value / 1000)
			elseif r.type == 'century' then
				r.value = ceil(r.value / 10)
			end
			r.type = 'millennium'
		elseif cutC and sv < cutC and (r.type == 'year' or r.type == 'decade') then
			r.value = ceil(r.value / 100)
			r.type = 'century'
		elseif cutD and sv < cutD and r.type == 'year' then
			r.value = floor(r.value / 10) * 10
			r.type = 'decade'
		end
	end
	
	local result = '[[' .. formatCat(r, cat, form) .. suffix .. ']]'
	
	if needsVerification(r, s) then
		result = result .. '[[' .. dateErrCat('требует проверки') .. ']]'
	end
	
	return result
end

return p