mirror of
https://github.com/NNTmux/newznab-tmux.git
synced 2026-08-28 17:01:16 +00:00
397 lines
13 KiB
PHP
397 lines
13 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Traits;
|
|
|
|
/**
|
|
* Trait for detecting hashed, obfuscated, and gibberish release names.
|
|
*
|
|
* Provides granular detection methods that can be used independently
|
|
* (for distinct matchedBy tags) or combined via isHashedOrGibberish().
|
|
*
|
|
* Used by both MiscCategorizer (categorization pipeline) and
|
|
* FileNameCleaner (name-fixing) to keep detection logic in sync.
|
|
*/
|
|
trait DetectsHashedNames
|
|
{
|
|
/**
|
|
* Combined check: returns true if the name appears hashed or gibberish.
|
|
*/
|
|
public function isHashedOrGibberish(string $name): bool
|
|
{
|
|
$cleaned = $this->stripExtensionsForAnalysis($name);
|
|
$coreName = $this->getCoreNameWithoutSeparators($cleaned);
|
|
|
|
return $this->isUuidPattern($coreName)
|
|
|| $this->isPureHexString($coreName)
|
|
|| $this->isBoundedMd5Hash($name)
|
|
|| $this->isBoundedSha1Hash($name)
|
|
|| $this->isBoundedSha256Hash($name)
|
|
|| $this->isBoundedGenericHash($name)
|
|
|| $this->isBase64LikeToken($name)
|
|
|| $this->isObfuscatedUppercaseString($name)
|
|
|| $this->isObfuscatedMixedAlphanumeric($name)
|
|
|| $this->isObfuscatedUsenetFilename($name)
|
|
|| $this->isRandomByCharacterAnalysis($coreName, $name)
|
|
|| $this->hasInsufficientWordStructure($coreName)
|
|
|| $this->isZeroVowelLongToken($coreName)
|
|
|| $this->isRandomDigitPattern($coreName);
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// Hex / hash boundary checks (operate on the raw release name)
|
|
// ---------------------------------------------------------------
|
|
|
|
/**
|
|
* Detect MD5 hash (32 hex characters) with word boundaries.
|
|
*/
|
|
protected function isBoundedMd5Hash(string $name): bool
|
|
{
|
|
return (bool) preg_match('/(?:^|["\'\s\[\]\/\-])([a-f0-9]{32})(?:["\'\s\[\]\/.\-]|$)/i', $name);
|
|
}
|
|
|
|
/**
|
|
* Detect SHA-1 hash (40 hex characters) with word boundaries.
|
|
*/
|
|
protected function isBoundedSha1Hash(string $name): bool
|
|
{
|
|
return (bool) preg_match('/(?:^|["\'\s\[\]\/\-])([a-f0-9]{40})(?:["\'\s\[\]\/.\-]|$)/i', $name);
|
|
}
|
|
|
|
/**
|
|
* Detect SHA-256 hash (64 hex characters) with word boundaries.
|
|
*/
|
|
protected function isBoundedSha256Hash(string $name): bool
|
|
{
|
|
return (bool) preg_match('/(?:^|["\'\s\[\]\/\-])([a-f0-9]{64})(?:["\'\s\[\]\/.\-]|$)/i', $name);
|
|
}
|
|
|
|
/**
|
|
* Detect generic long hex hash (32-128 chars) with word boundaries.
|
|
*/
|
|
protected function isBoundedGenericHash(string $name): bool
|
|
{
|
|
if (! preg_match('/(?:^|["\'\s\[\]\/\-])([a-z0-9]{24,128})(?:["\'\s\[\]\/.\-]|$)/i', $name, $matches)) {
|
|
return false;
|
|
}
|
|
|
|
$token = $matches[1];
|
|
|
|
if (preg_match('/\b(19|20)\d{2}\b/', $token)) {
|
|
return false;
|
|
}
|
|
|
|
return preg_match('/\d/', $token) === 1
|
|
&& $this->looksLikeRandomString($token);
|
|
}
|
|
|
|
/**
|
|
* Detect base64/base64url-like tokens that commonly appear in obfuscated subjects.
|
|
*/
|
|
protected function isBase64LikeToken(string $name): bool
|
|
{
|
|
if (! preg_match('/(?:^|["\'\s\[\]\/\-])([A-Za-z0-9+\/_=-]{24,})(?:["\'\s\[\]\/.\-]|$)/', $name, $matches)) {
|
|
return false;
|
|
}
|
|
|
|
$token = rtrim($matches[1], '=');
|
|
|
|
if (strlen($token) < 24 || preg_match('/\b(19|20)\d{2}\b/', $token)) {
|
|
return false;
|
|
}
|
|
|
|
if (! preg_match('/[A-Z]/', $token) || ! preg_match('/[a-z]/', $token) || ! preg_match('/\d/', $token)) {
|
|
return false;
|
|
}
|
|
|
|
return $this->looksLikeRandomString($token);
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// Core-name checks (operate on stripped / separator-free names)
|
|
// ---------------------------------------------------------------
|
|
|
|
/**
|
|
* Detect UUID patterns (with or without dashes stripped).
|
|
*/
|
|
protected function isUuidPattern(string $coreName): bool
|
|
{
|
|
return (bool) preg_match('/^[a-f0-9]{8}-?[a-f0-9]{4}-?[a-f0-9]{4}-?[a-f0-9]{4}-?[a-f0-9]{12}$/i', $coreName);
|
|
}
|
|
|
|
/**
|
|
* Detect pure hex strings of 16+ characters.
|
|
*/
|
|
protected function isPureHexString(string $coreName, int $minLength = 16): bool
|
|
{
|
|
return (bool) preg_match('/^[a-f0-9]{'.$minLength.',}$/i', $coreName);
|
|
}
|
|
|
|
/**
|
|
* Detect all-uppercase alphanumeric strings of 15+ characters.
|
|
*/
|
|
protected function isObfuscatedUppercaseString(string $name): bool
|
|
{
|
|
return (bool) preg_match('/^[A-Z0-9]{15,}$/', $name);
|
|
}
|
|
|
|
/**
|
|
* Detect mixed-case alphanumeric strings of 15+ characters
|
|
* that are not CamelCase words and contain no year pattern.
|
|
*/
|
|
protected function isObfuscatedMixedAlphanumeric(string $name): bool
|
|
{
|
|
return (bool) preg_match('/^[a-zA-Z0-9]{15,}$/', $name)
|
|
&& ! preg_match('/\b(19|20)\d{2}\b/', $name)
|
|
&& ! preg_match('/^[A-Z][a-z]+([A-Z][a-z]+)+$/', $name);
|
|
}
|
|
|
|
/**
|
|
* Detect obfuscated filenames embedded in usenet subject lines.
|
|
*
|
|
* Matches patterns like: [XX/XX] - "RANDOMSTRING.partXX.rar"
|
|
*/
|
|
protected function isObfuscatedUsenetFilename(string $name): bool
|
|
{
|
|
if (preg_match('/\[\d+\/\d+]\s*-\s*"([a-zA-Z0-9]{12,})\.(part\d+\.rar|7z\.\d{3}|rar|zip|vol\d+\+\d+\.par2|par2)"/i', $name, $matches)) {
|
|
$filename = $matches[1];
|
|
|
|
return ! preg_match('/[._ -]/', $filename)
|
|
&& ! preg_match('/\b(19|20)\d{2}\b/', $filename)
|
|
&& $this->looksLikeRandomString($filename);
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Detect names consisting of only punctuation and numbers with no clear structure.
|
|
*/
|
|
protected function isObfuscatedPunctuation(string $name): bool
|
|
{
|
|
return (bool) preg_match('/^[^a-zA-Z]*[A-Z0-9._\-]{5,}[^a-zA-Z]*$/', $name)
|
|
&& ! preg_match('/\.(mkv|avi|mp4|mp3|flac|pdf|epub|exe|iso)$/i', $name);
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// Character-analysis heuristics
|
|
// ---------------------------------------------------------------
|
|
|
|
/**
|
|
* Analyse character transitions to detect random strings.
|
|
*
|
|
* A high transition rate (upper↔lower, letter↔digit) with no
|
|
* media keywords in the original name suggests randomness.
|
|
*
|
|
* @param string $coreName Name with extensions and separators stripped.
|
|
* @param string $originalName The unmodified release name (for keyword check).
|
|
*/
|
|
protected function isRandomByCharacterAnalysis(string $coreName, string $originalName = ''): bool
|
|
{
|
|
$coreLen = strlen($coreName);
|
|
|
|
if ($coreLen < 16 || ! preg_match('/^[a-zA-Z0-9]+$/', $coreName)) {
|
|
return false;
|
|
}
|
|
|
|
$transitions = $this->countCharacterTransitions($coreName);
|
|
$transitionRate = $transitions / ($coreLen - 1);
|
|
|
|
if ($transitionRate > 0.35) {
|
|
$nameToCheck = $originalName !== '' ? $originalName : $coreName;
|
|
|
|
return ! preg_match(
|
|
'/\b(movie|film|series|episode|season|show|video|audio|music|album|dvd|bluray|hdtv|webrip|xvid|x264|x265|hevc|aac|mp3|flac|720p|1080p|2160p|4k|complete|proper|repack|dubbed|subbed|english|french|german|spanish|italian|rip|web|hdr|remux|disc|internal|retail)\b/i',
|
|
$nameToCheck
|
|
);
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Detect names that are long alphanumeric but lack word-like letter sequences.
|
|
*
|
|
* @param string $coreName Name with extensions and separators stripped.
|
|
*/
|
|
protected function hasInsufficientWordStructure(string $coreName): bool
|
|
{
|
|
$coreLen = strlen($coreName);
|
|
|
|
if ($coreLen < 20 || ! preg_match('/^[a-zA-Z0-9]+$/', $coreName)) {
|
|
return false;
|
|
}
|
|
|
|
$maxConsecutiveLetters = $this->getMaxConsecutiveLetters($coreName);
|
|
|
|
return $maxConsecutiveLetters < 5
|
|
&& ! preg_match('/^[a-zA-Z]+\d{1,4}$/', $coreName);
|
|
}
|
|
|
|
/**
|
|
* Detect random-looking patterns dominated by digits.
|
|
*
|
|
* @param string $coreName Name with extensions and separators stripped.
|
|
*/
|
|
protected function isRandomDigitPattern(string $coreName): bool
|
|
{
|
|
return (bool) (
|
|
preg_match('/^[a-zA-Z]{1,3}\d{6,}[a-zA-Z]*$/i', $coreName)
|
|
|| preg_match('/^[a-zA-Z0-9]{2,4}\d{8,}$/i', $coreName)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Detect long alphanumeric tokens with effectively no vowel structure.
|
|
*/
|
|
protected function isZeroVowelLongToken(string $coreName, int $minLength = 20): bool
|
|
{
|
|
if (strlen($coreName) < $minLength || ! preg_match('/^[a-zA-Z0-9]+$/', $coreName)) {
|
|
return false;
|
|
}
|
|
|
|
if (preg_match('/\b(19|20)\d{2}\b/', $coreName)) {
|
|
return false;
|
|
}
|
|
|
|
$letterOnly = preg_replace('/\d+/', '', $coreName);
|
|
|
|
if ($letterOnly === '' || strlen($letterOnly) < 10) {
|
|
return false;
|
|
}
|
|
|
|
return preg_match('/[aeiou]/i', $letterOnly) !== 1;
|
|
}
|
|
|
|
/**
|
|
* Check whether the letter-only portion of a token has a normal vowel ratio.
|
|
*/
|
|
protected function hasNormalVowelRatio(string $str, float $min = 0.2): bool
|
|
{
|
|
$letters = preg_replace('/[^a-z]/i', '', $str);
|
|
|
|
if ($letters === '' || strlen($letters) < 5) {
|
|
return false;
|
|
}
|
|
|
|
preg_match_all('/[aeiou]/i', $letters, $matches);
|
|
$vowelCount = count($matches[0]);
|
|
|
|
return ($vowelCount / strlen($letters)) >= $min;
|
|
}
|
|
|
|
/**
|
|
* Check if a string looks like a random/obfuscated string rather than a real title.
|
|
*
|
|
* Uses multiple heuristics: character-type transitions, consonant clusters,
|
|
* mixed case with digits, and vowel-consonant patterns.
|
|
*/
|
|
protected function looksLikeRandomString(string $str): bool
|
|
{
|
|
// All same-case letters → random if long enough
|
|
if (preg_match('/^[A-Z]+$/', $str) || preg_match('/^[a-z]+$/', $str)) {
|
|
return strlen($str) >= 12;
|
|
}
|
|
|
|
$len = strlen($str);
|
|
$transitions = $this->countCharacterTransitions($str);
|
|
$transitionRatio = $transitions / max(1, $len - 1);
|
|
|
|
// Check for common English consonant-vowel patterns
|
|
$hasWordPattern = preg_match(
|
|
'/[bcdfghjklmnpqrstvwxyz]{1,2}[aeiou][bcdfghjklmnpqrstvwxyz]{1,2}[aeiou]/i',
|
|
$str
|
|
);
|
|
|
|
// High transition ratio + no word patterns → random
|
|
if ($transitionRatio > 0.3 && ! $hasWordPattern) {
|
|
return true;
|
|
}
|
|
|
|
// Unlikely consonant clusters (≥5 consecutive consonants)
|
|
if (preg_match('/[bcdfghjklmnpqrstvwxyz]{5,}/i', $str)) {
|
|
return true;
|
|
}
|
|
|
|
// Mixed case AND digits with no clear structure
|
|
if (preg_match('/[A-Z]/', $str) && preg_match('/[a-z]/', $str) && preg_match('/\d/', $str)) {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// Helper utilities
|
|
// ---------------------------------------------------------------
|
|
|
|
/**
|
|
* Strip common file extensions for analysis.
|
|
*/
|
|
protected function stripExtensionsForAnalysis(string $name): string
|
|
{
|
|
return preg_replace(
|
|
'/\.(mkv|avi|mp4|m4v|mpg|mpeg|wmv|flv|mov|ts|vob|iso|divx|par2?|nfo|sfv|nzb|rar|r\d{2,3}|zip|7z|gz|tar|001|msix|msixbundle|appx|appxbundle|apk|xap|ipa|deb|rpm|pkg|dmg|exe|msi)$/i',
|
|
'',
|
|
trim($name)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Get core name by removing all separators.
|
|
*/
|
|
protected function getCoreNameWithoutSeparators(string $cleaned): string
|
|
{
|
|
return preg_replace('/[.\-_\s]+/', '', $cleaned);
|
|
}
|
|
|
|
/**
|
|
* Count character-type transitions (upper↔lower, letter↔digit).
|
|
*/
|
|
protected function countCharacterTransitions(string $str): int
|
|
{
|
|
$transitions = 0;
|
|
$len = strlen($str);
|
|
|
|
for ($i = 1; $i < $len; $i++) {
|
|
$prev = $str[$i - 1];
|
|
$curr = $str[$i];
|
|
|
|
$prevIsDigit = ctype_digit($prev);
|
|
$currIsDigit = ctype_digit($curr);
|
|
$prevIsUpper = ctype_upper($prev);
|
|
$currIsUpper = ctype_upper($curr);
|
|
|
|
if ($prevIsDigit !== $currIsDigit) {
|
|
$transitions++;
|
|
} elseif (! $prevIsDigit && ! $currIsDigit && $prevIsUpper !== $currIsUpper) {
|
|
$transitions++;
|
|
}
|
|
}
|
|
|
|
return $transitions;
|
|
}
|
|
|
|
/**
|
|
* Get the maximum consecutive letter count in a string.
|
|
*/
|
|
protected function getMaxConsecutiveLetters(string $str): int
|
|
{
|
|
$maxConsecutive = 0;
|
|
$currentConsecutive = 0;
|
|
$lastWasLetter = false;
|
|
|
|
for ($i = 0, $len = strlen($str); $i < $len; $i++) {
|
|
$isLetter = ctype_alpha($str[$i]);
|
|
if ($isLetter) {
|
|
$currentConsecutive = $lastWasLetter ? $currentConsecutive + 1 : 1;
|
|
$maxConsecutive = max($maxConsecutive, $currentConsecutive);
|
|
}
|
|
$lastWasLetter = $isLetter;
|
|
}
|
|
|
|
return $maxConsecutive;
|
|
}
|
|
}
|