Catalyst / admin/Bid-Sentinel 14.8 GB / 57.8 GB 40.0 GB free
Help Sign in

admin / Bid-Sentinel

public

Bid Scrape and Tracking Application with AI Capability

Code Issues Pull requests Pipelines Packages Security Insights Wiki Settings
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