admin / Bid-Sentinel
publicBid Scrape and Tracking Application with AI Capability
Bid-Sentinel / bid-sentinel-v2 / backend / app / scraper / keywords.py
7511 B · main
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 | """Cyber Security keyword filters used to target relevant tenders.""" # Order matters: match_keyword() returns the FIRST entry found, so specific # multi-word phrases are listed before short acronyms to produce the most # meaningful "matched keyword" label. Acronyms (len <= 4) are matched with word # boundaries (see match_keyword) to avoid false positives. CYBER_KEYWORDS: list[str] = [ # --- 1. Core services (specific phrases) --- "Managed Detection and Response", "Security Operations Centre", "Security Operations Center", "24x7 SOC Monitoring", "Incident Response", "Vulnerability Management", "Cyber Threat Intelligence", "Threat Hunting", # --- 1b. Additional high-signal cyber terms (strong; added to widen recall) --- "Cyber Security", "Cybersecurity", "Cyber Resilience", "Information Security", "Information Assurance", "Managed Security Service", # also matches "Managed Security Services" "Security Operations", "Security Monitoring", "Security Testing", "Security Assurance", "Security Audit", "Vulnerability Assessment", "Vulnerability Scanning", "Digital Forensics", "Identity and Access Management", "Privileged Access Management", "Data Loss Prevention", "ISO 27001", "ISO27001", "SOC 2", "SOC2", # --- 2. Acronym expansions (phrases) --- "Cyber Security Operations Centre", "Extended Detection and Response", "Endpoint Detection and Response", "Network Detection and Response", "Security Information and Event Management", # --- 3. UK Government & NCSC terminology --- "Cyber Essentials Plus", "Cyber Assessment Framework", "CHECK Penetration Testing", "CHECK provider", "GovAssure", "Cyber Security Model", "Secure by Design", # --- 4. Broader technical domains (use with caution; see note below) --- "Penetration Testing", "Pen Testing", "Red Teaming", "Security Architecture", "Zero Trust", "Cloud Security", # --- 5. Industry acronyms & variations (word-boundary matched) --- "CSOC", "SIEM", "ZTNA", "MDR", "XDR", "EDR", "NDR", "CTI", "CAF", "CSM", "MSSP", "NIST", "DLP", "IAM", "SOC", ] # Group-4 "broad" terms appear in generic IT / architecture contracts too, so a # match on one of these ALONE is not enough: it is only treated as relevant when # a second cyber signal is present (another keyword, a second broad term, or a # corroborating context word). This is the AND-gate. BROAD_KEYWORDS: set[str] = { "Penetration Testing", "Pen Testing", "Red Teaming", "Security Architecture", "Zero Trust", "Cloud Security", } # Corroborating cyber-context words. Deliberately excludes bare "security" # (too generic) and any term already in CYBER_KEYWORDS (those return on their own # before the corroboration check is ever reached). CYBER_CONTEXT_TERMS: list[str] = [ "cyber", "threat", "malware", "ransomware", "phishing", "intrusion", "firewall", "monitoring", "detection", "vulnerabilit", # vulnerability / vulnerabilities "ncsc", "security operations", "endpoint", ] def _has_cyber_context(haystack: str) -> bool: return any(term in haystack for term in CYBER_CONTEXT_TERMS) def match_keyword(*texts: str | None, keywords: list[str] | None = None) -> str | None: """Return the matched keyword for the given text blobs, or None. The most specific non-broad keyword wins (phrases are listed before acronyms). Short acronyms (MDR, SOC, CTI) are matched with word boundaries to avoid false positives like "soccer" or "association". Group-4 broad terms (see ``BROAD_KEYWORDS``) only count when accompanied by a second cyber signal. ``keywords`` defaults to the built-in list; callers pass the combined built-in + custom list (custom keywords are always treated as strong signals). """ import re haystack = " ".join(t for t in texts if t).lower() if not haystack: return None def _present(kw_l: str) -> bool: if len(kw_l) <= 4: # acronym -> word-boundary match return re.search(rf"\b{re.escape(kw_l)}\b", haystack) is not None return kw_l in haystack broad_hits: list[str] = [] for kw in keywords or CYBER_KEYWORDS: kw_l = kw.lower().strip() if not kw_l or not _present(kw_l): continue if kw in BROAD_KEYWORDS: broad_hits.append(kw) else: return kw # strong, non-broad signal -> relevant immediately # Only broad terms matched -> require a second corroborating signal. if broad_hits and (len(broad_hits) >= 2 or _has_cyber_context(haystack)): return broad_hits[0] return None def passes_cpv_filter( title: str | None, description: str | None, cpv_blob: str | None, *, cpv_terms: list[str] | None, strict: bool = False, ) -> bool: """Second-stage CPV filter, ANDed with the keyword match. When the user has configured CPV codes, an opportunity is kept only if one of those codes/descriptions appears (in the notice's structured CPV classification ``cpv_blob`` or its text). With no CPV codes configured the filter is open. ``cpv_blob`` is the notice's declared CPV classification (empty for sources that don't expose CPV, e.g. HTML portals). In the default (lenient) mode, a notice that declares NO CPV data is NOT excluded on CPV grounds — keyword relevance already applied, and there is no classification to judge it on. Set ``strict=True`` to require a CPV match from every notice regardless. """ if not cpv_terms: return True haystack = " ".join(t for t in (title, description, cpv_blob) if t).lower() for term in cpv_terms: term_l = (term or "").strip().lower() if term_l and term_l in haystack: return True # No CPV term matched. In lenient mode, keep notices that carry no CPV data at # all (nothing to narrow on); in strict mode, drop them. if not strict and not (cpv_blob or "").strip(): return True return False def is_cyber_relevant(*texts: str | None) -> bool: """True if the text carries a genuine cyber-security signal. Uses the built-in cyber keyword list (broad terms still require corroboration), which is the definition of "Cyber Security related". Used as a guardrail so the tune-out filter can never suppress a cyber opportunity. """ return match_keyword(*texts, keywords=CYBER_KEYWORDS) is not None def matches_tuneout( title: str | None, description: str | None, terms: list[str] | None ) -> bool: """Whether an opportunity should be tuned out (hidden from scrapes/dashboard). An opportunity is tuned out only when it matches one of the user's tune-out terms AND is NOT cyber-security related. Cyber opportunities are always kept, no matter what is on the tune-out list — this is the hard guardrail. """ if not terms: return False if is_cyber_relevant(title, description): return False # never tune out anything cyber-security related haystack = " ".join(t for t in (title, description) if t).lower() if not haystack: return False for term in terms: term_l = (term or "").strip().lower() if term_l and term_l in haystack: return True return False |