对央国企网页源码新闻列表进行抓取的规则引擎改造

37 阅读14分钟

**注:文章仅作自己学习和记录使用 **

任务:央国企官网企业新闻和资讯的爬虫

查看了几家央企的网页源代码,发现基本都是可以直接从源代码获取到新闻列表的。使用requests获取网页源码,然后bs4解析一下,直接找新闻中心下的li标签,就可以获取新闻列表。

def main():
    url='http://www.csic.com.cn/index.html'
    host='http://www.csic.com.cn/'
    resp=requests.get(url)
    resp.encoding='utf-8'
    soup=bs4.BeautifulSoup(resp.text,'html.parser')#创建bs4对象
    # print(soup.prettify())
    url_dict={}

    for a in soup.find_all('a',href=True):
        if a.span and (a in soup.select('a.index_nav_item')):
            if (a.span.string=='新闻中心') or (a.span.string=='组织机构')   :
                url_dict[a.span.string]=a['href']
            else:
                continue
    print(url_dict)
    for key,value in url_dict.items():
        if key=='新闻中心':
            print('下面进入新闻中心:\n')
            get_news(host+value)
        else:
            print('下面进入组织机构:\n')
            get_org(host+value)

def get_news(news_url):
    resp=requests.get(news_url)
    resp.encoding='utf-8'
    soup=bs4.BeautifulSoup(resp.text,'html.parser')#创建bs4对象
    # 将两个CSS选择器合并到一个字符串中,用逗号分隔
    news_lists = soup.select('ul.jtxx_focus_list, ul.mtjj_list')
    print("这是所有可能的新闻列表:" + str(news_lists))
    print("--------------------------------")
    for news_list in news_lists:
        # 遍历每个ul下的所有li
        for item in news_list.find_all('li'):
            # 确保li下有a标签
            if item.a:
                href = item.a.get('href')
                title = item.a.get('title')
                print(href, title)


def get_org(org_url):
    resp=requests.get(org_url)
    resp.encoding='utf-8'
    soup=bs4.BeautifulSoup(resp.text,'html.parser')
    print(soup.prettify())


 if __name__ == '__main__':
     main()

但这样只能满足一个官网的数据获取,更换别的官网,可能就会因为网页排版和代码风格等失效。所以要做一个通用的企业官网数据提取工具。仔细查看几个官网源代码后,发现新闻链接的列表都有一些共性,即都包含链接,大多数包含日期,文本量都比较大等。初步思路是写一个去除噪声只保留新闻链接列表元素的算法进行数据清洗,把清洗后的数据给到llm去进行格式化的提取。算法如下:标签文本率x权重+链接密度x权重+最大深度奖励x权重+日期奖励x权重=内容块得分,得分>阈值即保留。

#去噪算法
def block_score(node):
    """给任意节点打分,越高越可能是新闻列表"""
    # 获取节点的纯文本内容,用于后续分析
    raw_text = node.get_text(' ', strip=True)

    # 日期检测
    # 正则表达式匹配 YYYY-MM-DD, MM/DD, MM-DD 等常见日期格式
    date_pattern = re.compile(r'\b(\d{4}-\d{1,2}-\d{1,2}|\d{1,2}[-/]\d{1,2})\b')
    dates_found = date_pattern.findall(raw_text)
    
    date_bonus = 0
    if len(dates_found) >= 2:
        date_bonus = 100  # 如果找到2个及以上日期,给予高额加分

    #全部文本及长度
    visible_text = re.sub(r'\s+', '', raw_text)
    txt_len = len(visible_text)
    #全部a标签及长度
    a_tags = node.find_all('a')
    total_tags = len(node.find_all())
    
    #最大深度
    max_depth = max([len(list(anc.parents)) for anc in node.find_all()] + [0])

    text_dense = txt_len / (1 + total_tags)          # 1.文本量/标签量
    link_dense = len(a_tags) / (1 + total_tags)      # 2. 链接密度
    depth_bonus = max_depth                          # 3. 最大深度
    
    # 最终得分
    final_score = text_dense * 0.6 + link_dense * 100 + depth_bonus * 2 + date_bonus
    return final_score

def split_blocks(html, top_k=3,min_text=60,min_a=2,min_link_ratio=0.5,max_link_ratio=0.8):

    soup = bs4.BeautifulSoup(html, 'html.parser')
    # 只考察“可能容器”
    candidates = soup.find_all(['div', 'ul', 'section', 'article','body'])
    scored = []
    for node in candidates:
        total_text = node.get_text(' ', strip=True)
        if len(total_text) < min_text or len(node.find_all('a')) < min_a:
            continue
        link_text=''.join(a.get_text(' ',strip=True) for a in node.find_all('a'))
        link_ratio=float(len(link_text)/(len(total_text)+1))
        #debug
        # print(f"整个结点{str(node)}的链接密度为{link_ratio}")
        if  not (min_link_ratio<link_ratio<max_link_ratio):
            continue
        scored.append((block_score(node), node))
    scored.sort(key=lambda x: x[0], reverse=True)

    # 去重
    unique_nodes = []
    # 遍历所有排序后的候选块
    for score, node in scored:
        is_redundant = False
        # 检查当前块是否是任何一个已选块的子部分
        for unique_node in unique_nodes:
            if node in unique_node.find_all(True):
                is_redundant = True
                break
        
        # 如果不是重复的,就将其选中
        if not is_redundant:
            unique_nodes.append(node)
        
        # 如果已经选够了 top_k 个,就提前结束
        if len(unique_nodes) >= top_k:
            break
            
    return unique_nodes

将算法改造成规则引擎,方便业务调试。加入关键词模糊匹配初筛和算法筛选出来的内容段内噪声惩罚机制,现在的算法:标签文本率x权重+链接密度x权重+最大深度奖励x权重+日期奖励x权重-噪声惩罚=内容块得分。

def _compile_pre_filter_patterns(self):
        """
        预编译 pre_filter 的正则表达式,提高效率
        用yaml文件中配置的关键词进行模糊匹配的正则表达式
        """
        keyword_patterns = self.cfg.get('pre_filter', {}).get('keyword_selectors', [])
        self.keyword_re = re.compile('|'.join(keyword_patterns), re.I) if keyword_patterns else None
        
        text_selector_config = self.cfg.get('pre_filter', {}).get('text_selectors', {})
        text_pattern = text_selector_config.get('pattern')
        self.text_re = re.compile(text_pattern) if text_pattern else None
        self.text_tags = text_selector_config.get('tags', ['h1', 'h2', 'h3', 'h4', 'strong', 'span'])
        noise_pattern = text_selector_config.get('noise_pattern', '')
        self.noise_tags = text_selector_config.get('noise_tags', [])
        #噪声,用于惩罚扣分 ---
        self.noise_re = re.compile(
            r'{noise_pattern}'.format(noise_pattern=noise_pattern), 
            re.I
        )

    def _pre_filter(self, soup: BeautifulSoup) -> List[Any]:
        """执行预筛选逻辑,从 soup 中找到候选父节点"""
        candidates = set()
        if self.keyword_re:
            for tag in soup.find_all(attrs={'id': self.keyword_re}):
                candidates.add(tag)
            for tag in soup.find_all(attrs={'class': self.keyword_re}):
                candidates.add(tag)
        
        if self.text_re:
            for tag in soup.find_all(self.text_tags, string=self.text_re):
                parent = tag.find_parent(['div', 'ul', 'section', 'article'])
                if parent:
                    candidates.add(parent)

        if not candidates:
            body = soup.find('body')
            if body: return [body]

        return list(candidates)

    def _get_node_features(self, node: Any) -> Dict[str, Any]:
        """计算并返回一个节点的所有相关特征"""
        total_text = node.get_text(' ', strip=True)
        a_tags = node.find_all('a')
        link_text = ''.join(a.get_text(' ', strip=True) for a in a_tags)
        # 针对源码写的非常烂的网站的优化措施——增加噪声惩罚
        # 计算噪音密度
        noise_text_len = 0
        # 1. 查找语义化的噪音标签
        if self.noise_tags:
            for tag in node.find_all(self.noise_tags):
                noise_text_len += len(tag.get_text(' ', strip=True))

        # 2. 查找 class/id 中包含噪音关键词的标签
        for tag in node.find_all(attrs={'id': self.noise_re}):
            noise_text_len += len(tag.get_text(' ', strip=True))
        for tag in node.find_all(attrs={'class': self.noise_re}):
            noise_text_len += len(tag.get_text(' ', strip=True))
        
        total_text_len = len(total_text)
        
        return {
            "total_text_len": total_text_len,
            "a_tags_count": len(a_tags),
            "link_ratio": len(link_text) / (total_text_len + 1e-6),
            "date_count": len(re.findall(r'\b(\d{4}[-/.]\d{1,2}[-/.]\d{1,2}|\d{1,2}[-/]\d{1,2})\b', total_text)),
            "total_tags_count": len(node.find_all(True)),
            "max_depth": max([len(list(anc.parents)) for anc in node.find_all()] + [0]),
            "list_tags_count": len(node.find_all(['ul', 'li', 'dl', 'dd'])),
            "noise_density": noise_text_len / (total_text_len + 1e-6), # 噪音文本长度占总文本长度的比例
            "script_tags_count": len(node.find_all('script')), # script标签数量
            "iframe_tags_count": len(node.find_all('iframe'))  # iframe标签数量
        }

    def _apply_filter(self, features: Dict, rule: Dict) -> bool:
        """根据单条规则对特征进行过滤"""
        field_value = features.get(rule['field'])
        if field_value is None:
            return False # 如果特征不存在,则过滤掉

        op, val = rule['operator'], rule['value']
        if op == '>=': return field_value >= val
        if op == '<=': return field_value <= val
        if op == 'between': return val[0] <= field_value <= val[1]
        return True

    def score(self, features: Dict, debug: bool = False) -> Any:
        """根据评分规则计算节点的总分。如果 debug=True,则返回分数和详情。"""
        score = 0.0
        score_details = {}
        for sc in self.cfg['scorers']:
            feature_val = features.get(sc['field'])
            if feature_val is not None:
                component_score = feature_val * sc['weight']
                score += component_score
                if debug:
                    score_details[sc['name']] = round(component_score, 2)
        
        if debug:
            return round(score, 2), score_details
        return score

    def pick(self, soup: BeautifulSoup, top_k: int = 3, debug: bool = False) -> Any:
        """
        引擎主入口:执行预筛选、精细过滤、评分和去重,选出最佳的 top_k 个节点。
        如果 debug=True,则返回 (unique_nodes, debug_info)
        """
        # 1. 预筛选
        parent_nodes = self._pre_filter(soup)
        if not parent_nodes:
            return []

        # 2. 在候选区域内查找所有可能的子容器
        all_sub_candidates = set()
        for node in parent_nodes:
            sub_nodes = node.find_all(['div', 'ul', 'section', 'article', 'body'])
            all_sub_candidates.update(sub_nodes)
            if node.name in ['div', 'ul', 'section', 'article', 'body']:
                all_sub_candidates.add(node)
        
        # 3. 对所有子容器进行过滤和评分
        scored_nodes = []
        all_passed_nodes_info = [] # Will store debug info for nodes that passed filters

        for n in all_sub_candidates:
            features = self._get_node_features(n)
            if all(self._apply_filter(features, r) for r in self.cfg['filters']):
                if debug:
                    total_score, score_details = self.score(features, debug=True)
                    node_info = {
                        'score': total_score,
                        'node': n,
                        'score_details': score_details
                    }
                    all_passed_nodes_info.append(node_info)
                    scored_nodes.append((total_score, n))
                else:
                    score = self.score(features)
                    scored_nodes.append((score, n))
        
        # 4. 排序和智能去重
        scored_nodes.sort(key=lambda x: x[0], reverse=True)
        
        unique_nodes = []
        for _, node in scored_nodes:
            is_redundant = False
            for unique_node in unique_nodes:
                if node in unique_node.find_all(True):
                    is_redundant = True
                    break
            if not is_redundant:
                unique_nodes.append(node)
            if len(unique_nodes) >= top_k:
                break
                
        if debug:
            return unique_nodes, all_passed_nodes_info
        
        return unique_nodes
# 规则引擎配置文件

# 预筛选规则,用于从整个页面快速定位候选区域
pre_filter:
  keyword_selectors:
    # 匹配 id 和 class 属性中包含的关键词 (不区分大小写)
    - news|xinwen
    - list|liebiao
    - center|zhongxin
    - dynamic|dongtai
    - info|zixun
  text_selectors:
    # 匹配 h1-h4, strong, span 等标签的文本关键词
    tags: ['h1', 'h2', 'h3', 'h4', 'strong', 'span']
    pattern: '新闻|动态|资讯|要闻|一线|news|dynamic|info|important|line' #初筛的关键词
    noise_pattern: 'nav|menu|footer|header|sidebar|copyright|breadcrumb|pagination|form|search|login|banner|aside|script|iframe|button' #噪声关键词,用于惩罚扣分 ---
    noise_tags: ['nav', 'header', 'footer', 'aside', 'form', 'script', 'iframe', 'button'] #用于惩罚的噪音标签

# 新闻列表块的精细化筛选和评分规则
filters:
  - name: min_text_len
    field: total_text_len
    operator: '>='
    value: 40 # 降低文本长度要求,允许更小的块

  - name: min_a_tags
    field: a_tags_count
    operator: '>='
    value: 2 # 至少需要2个链接

  - name: link_ratio_range
    field: link_ratio
    operator: between
    value: [0.5, 0.95]

scorers:
  - name: date_bonus
    field: date_count
    weight: 100 # 显著提高日期特征的权重

  - name: depth_bonus
    field: max_depth
    weight: 2
    
  - name: link_count_bonus
    field: a_tags_count
    weight: 10

  - name: list_tags_bonus #对包含列表标签的块加分
    field: list_tags_count # (ul, li, dl, dd)
    weight: 20

  - name: noise_penalty # 对包含噪音内容的块进行惩罚
    field: noise_density
    weight: -200

  - name: script_penalty # 惩罚包含过多脚本的块
    field: script_tags_count
    weight: -50

  - name: iframe_penalty # 惩罚包含iframe的块
    field: iframe_tags_count
    weight: -200

多使用了几家企业官网进行测试。效果不是特别理想,特别是对于那些官网源码写的特别烂的企业,筛选出来的噪声量还是比较大。虽然可以使用了,但是清洗不完全的数据喂给llm会给llm带来很大的不必要的负担。思考该怎么去解决这个问题,对算法进行了多次迭代,并且加入内容块内二次筛查机制后,还是会有一小部分情况会把这种

<div class="mo_yi_w lhd_2 clearfix">                                                                                                                                                          |
| --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| <a class="mo_yi" href="[/cnnc/jtgk/jtjs/index.html](https://www.cnnc.com.cn/cnnc/jtgk/jtjs/index.html)">集团概况</a>                                                                              |
| <button class="mo_yi_btn"></button> </div>                                                                                                                                                    |
| <div class="mo_er_w qs_clear h_2 clearfix">                                                                                                                                                   |
| <a class=" mo_er " target="_parent" href="[/cnnc/jtgk/jtjs/index.html](https://www.cnnc.com.cn/cnnc/jtgk/jtjs/index.html)">集团介绍</a>                                                           |
| <a class=" mo_er " target="_parent" href="[/cnnc/jtgk/dsh/index.html](https://www.cnnc.com.cn/cnnc/jtgk/dsh/index.html)">董事会</a>                                                              |
| <a class=" mo_er " target="_parent" href="[/cnnc/jtgk/ldbz/index.html](https://www.cnnc.com.cn/cnnc/jtgk/ldbz/index.html)">领导班子</a>                                                           |
| <a class=" mo_er " target="_parent" href="[/cnnc/jtgk/9fc5f21ac86f4822ad4d663c4cf9ff9b/index.html](https://www.cnnc.com.cn/cnnc/jtgk/9fc5f21ac86f4822ad4d663c4cf9ff9b/index.html)">成员单位站点</a> |
| </div>                                                                                                                                                                                        |
| <div class="mo_yi_w lhd_3 clearfix">                                                                                                                                                          |
| <a class="mo_yi" href="[/cnnc/xwzx65/ttyw01/index.html](https://www.cnnc.com.cn/cnnc/xwzx65/ttyw01/index.html)">新闻中心</a>                                                                      |
| <button class="mo_yi_btn"></button> </div>                                                                                                                                                    |
| <div class="mo_er_w qs_clear h_3 clearfix">                                                                                                                                                   |
| <a class=" mo_er " target="_parent" href="[/cnnc/xwzx65/ttyw01/index.html](https://www.cnnc.com.cn/cnnc/xwzx65/ttyw01/index.html)">头条新闻</a>                                                   |
| <a class=" mo_er " target="_parent" href="[/cnnc/xwzx65/zhyw0/index.html](https://www.cnnc.com.cn/cnnc/xwzx65/zhyw0/index.html)">中核要闻</a>                                                     |
| <a class=" mo_er " target="_parent" href="[/cnnc/xwzx65/yxdt10/index.html](https://www.cnnc.com.cn/cnnc/xwzx65/yxdt10/index.html)">一线动态</a>                                                   |
| <a class=" mo_er " target="_parent" href="[/cnnc/xwzx65/spxw/index.html](https://www.cnnc.com.cn/cnnc/xwzx65/spxw/index.html)">视频新闻</a>                                                       |
| <a class=" mo_er " target="_parent" href="[/cnnc/xwzx65/mtjj91/index.html](https://www.cnnc.com.cn/cnnc/xwzx65/mtjj91/index.html)">媒体聚焦</a>                                                   |
| <a class=" mo_er " target="_parent" href="[/cnnc/xwzx65/bzqk/index.html](https://www.cnnc.com.cn/cnnc/xwzx65/bzqk/index.html)">报纸期刊</a>                                                       |
| <a class=" mo_er " target="_parent" href="[/cnnc/xwzx65/ztbd74/index.html](https://www.cnnc.com.cn/cnnc/xwzx65/ztbd74/index.html)">专题报道</a>                                                   |
| </div>                                                                                                                                                                                        |
| <div class="mo_yi_w lhd_4 clearfix">

菜单选项的噪声数据当成新闻列表筛选出来。原因在于当前算法总是基于关键词进行的模式识别,极易被误导,预筛选确实在左侧导航栏里找到了一个 新闻 相关的标签。于是把整个左侧导航栏甚至更大的父容器选为了“候选区”,这直接导致了后续的灾难性评分。以及对“链接数量”的盲目崇拜:link_count_bonus 的权重很高,导致引擎认为链接越多越好。而除了新闻列表外什么地方链接最多,那就是导航栏、友情链接、网站地图。 这导致引擎频繁地给这些最大的噪声源打出最高分。即使没有日期奖励加上噪声惩罚,链接密度和文本率等得分还是很高以至于总体得分偏高。于是这个算法到瓶颈了,因为再怎么调参数加奖励惩罚都无法解决这种高额得分的问题。

所以调整的方向是要让引擎理解内容段的结构模式。引擎得知道:“一个HTML块,如果它是一个新闻列表,那么它在数据特征上应该呈现出什么样子?”于是想到特征工程,特征工程是指从原始数据中提取、选择、构造、转换出对模型预测最有帮助的特征(输入变量)的技术和方法。首先需要知道新闻列表有哪些特征,其一是内容(数据)特征:文本长度,链接文本比例,噪声密度,日期数量等。其二是结构特征:子节点结构一致性,标题链接同步性等。提取出这些特征后,规则引擎会对拿到的每个数据块检查是否有这些特征,特征水平怎么样,如果这些特征都满足就直接放行。然后对不全满足的但是评分很高的数据块,根据如下四个启发式规则再进行一次筛选:1.必须包含足够数量的链接 2.文本内容不能太短 3.是否包含黑名单关键词,包含几个 4.链接文本比例不能太高。这种新的特征工程和启发式规则并用的新的规则引擎实测在多家企业官网上表现良好。

def _apply_heuristic_rules(self, node: Any) -> tuple[bool, str]:
        """
        应用一组启发式规则来判断一个节点是否是有效的新闻列表片段。
        所有参数从 self.cfg 的 'heuristic_rules' 部分读取。
        """
        rules_cfg = self.cfg.get('heuristic_rules', {})
        min_text_len = rules_cfg.get('min_text_len', 50)
        max_link_ratio = rules_cfg.get('max_link_ratio', 0.95)
        min_a_tags = rules_cfg.get('min_a_tags', 2)
        blacklist = rules_cfg.get('blacklist', [])
        blacklist_threshold = rules_cfg.get('blacklist_threshold', 3)

        if not blacklist:
            self.logger.warning("heuristic_rules.blacklist 在配置文件中为空或未定义,黑名单规则可能失效。")

        # 规则1: 必须包含足够数量的链接
        a_tags = node.find_all('a')
        if len(a_tags) < min_a_tags:
            return False, f"链接数量 {len(a_tags)} < {min_a_tags}"

        text = node.get_text(" ", strip=True)
        
        # 规则2: 文本内容不能太短
        if len(text) < min_text_len:
            return False, f"文本总长度 {len(text)} < {min_text_len}"

        # 规则3: 检查是否包含黑名单关键词 (达到阈值才触发)
        matched_keywords = {keyword for keyword in blacklist if keyword in text}
        if len(matched_keywords) >= blacklist_threshold:
            return False, f"包含 {len(matched_keywords)} 个黑名单关键词 (>{blacklist_threshold}): {', '.join(matched_keywords)}"

        # 规则4: 链接文本比例不能过高
        link_text_len = len("".join(a.get_text(" ", strip=True) for a in a_tags))
        total_text_len = len(text)
        link_ratio = link_text_len / (total_text_len + 1e-6)
        if link_ratio > max_link_ratio:
            return False, f"链接文本比例 {link_ratio:.2f} > {max_link_ratio}"
        
        return True, "符合所有启发式规则"

    def _chunk_node(self, node: Any, max_chunk_size: int = 4000) -> List[Any]:
        """
        对一个大的HTML节点进行智能分块,避免喂给llm非常大的代码段
        它会识别出节点内的原子单位(如<li>),并确保不会在原子单位中间进行切割。
        """
        # 识别原子单位:优先是直接的 li 子节点,其次是直接的 div 子节点
        atomic_units = node.find_all('li', recursive=False)
        if not atomic_units:
            atomic_units = node.find_all('div', recursive=False)
        
        # 如果找不到明确的原子单位,或者只有一个,则无法切分,返回原节点
        if len(atomic_units) <= 1:
            return [node]

        chunks = []
        current_chunk_items = []
        current_chunk_size = 0

        for unit in atomic_units:
            unit_size = len(str(unit))
            # 如果当前块加上新单元会超长,并且当前块不为空,则先打包当前块
            if current_chunk_size + unit_size > max_chunk_size and current_chunk_items:
                # 创建一个新的父容器,并将当前块的item放进去
                new_chunk_root = BeautifulSoup(f'<{node.name}></{node.name}>', 'html.parser').find(node.name)
                for item in current_chunk_items:
                    new_chunk_root.append(item)
                chunks.append(new_chunk_root)
                
                # 重置,开始新的块
                current_chunk_items = [unit]
                current_chunk_size = unit_size
            else:
                # 否则,继续添加
                current_chunk_items.append(unit)
                current_chunk_size += unit_size
        
        # 处理最后一个块
        if current_chunk_items:
            new_chunk_root = BeautifulSoup(f'<{node.name}></{node.name}>', 'html.parser').find(node.name)
            for item in current_chunk_items:
                new_chunk_root.append(item)
            chunks.append(new_chunk_root)

        self.logger.info(f"    - 一个大节点被智能切分为 {len(chunks)} 个小片段。")
        return chunks

    def _get_initial_candidates(self, soup: BeautifulSoup) -> List[Any]:
        """
        广撒网策略,获取页面上所有可能的容器块作为候选。
        取代之前基于关键词的脆弱的预筛选。
        """
        candidates = soup.find_all(['div', 'ul', 'section', 'article'])
        if not candidates:
            body = soup.find('body')
            if body:
                return [body]
        return candidates

    def _get_node_features(self, node: Any) -> Dict[str, Any]:
        """计算并返回一个节点的所有相关特征,包含结构化特征。"""
        #特征工程第一步:特征计算
        total_text = node.get_text(' ', strip=True) #所有文本内容
        a_tags = node.find_all('a', recursive=True) #所有链接
        link_text = ''.join(a.get_text(' ', strip=True) for a in a_tags) #链接文本
        total_text_len = len(total_text) #文本长度

        noise_text_len = 0 #噪声文本长度
        if self.noise_tags:
            for tag in node.find_all(self.noise_tags):
                noise_text_len += len(tag.get_text(' ', strip=True))
        for tag in node.find_all(attrs={'id': self.noise_re}): #id中包含噪声关键词的标签
            noise_text_len += len(tag.get_text(' ', strip=True))
        for tag in node.find_all(attrs={'class': self.noise_re}): #class中包含噪声关键词的标签
            noise_text_len += len(tag.get_text(' ', strip=True))
        

        date_count = len(re.findall(r'(\d{4}[-./年]\d{1,2}[-./月]\d{1,2})|(\d{1,2}[-./月]\d{1,2})', total_text)) #日期数量

        #结构化模式识别特征

        # 子节点一致性 
        direct_children = [child for child in node.find_all(recursive=False) if hasattr(child, 'name')] #直接子节点
        child_consistency_score = 0.0 #子节点一致性得分
        item_count = 0 #子节点数量
        if len(direct_children) > 2:
            signatures = [] #签名
            valid_children_for_sig = [c for c in direct_children if len(c.get_text(strip=True)) > 5] #有效的子节点
            for child in valid_children_for_sig:
                sig = (child.name, len(child.find_all('a')), len(child.find_all('span'))) 
                signatures.append(sig)
            
            if signatures:
                sig_counts = Counter(signatures) #签名计数
                most_common_sig, count = sig_counts.most_common(1)[0] 
                item_count = count
                child_consistency_score = count / len(signatures) #子节点一致性得分
        
        if item_count < 2:
            li_tags = node.find_all('li', recursive=False) #所有li标签
            if not li_tags:
                 li_tags = node.find_all('li')
            item_count = len(li_tags)

        #日期密度 
        date_density = date_count / (item_count + 1e-6)

        # 标题化链接 
        headline_links = [a for a in a_tags if len(a.get_text(strip=True)) > 8]
        headline_link_count = len(headline_links)
        
        return {
            # 返回特征
            "total_text_len": total_text_len,
            "a_tags_count": len(a_tags),
            "link_ratio": len(link_text) / (total_text_len + 1e-6),
            "total_tags_count": len(node.find_all(True)),
            "noise_density": noise_text_len / (total_text_len + 1e-6),
            "script_tags_count": len(node.find_all('script')),
            "iframe_tags_count": len(node.find_all('iframe')),
            "date_count": date_count,
            "child_consistency_score": child_consistency_score,
            "date_density": date_density,
            "headline_link_count": headline_link_count,
            "headline_link_ratio": headline_link_count / (len(a_tags) + 1e-6),
        }

    def _apply_filter(self, features: Dict, rule: Dict) -> bool:
        """根据单条规则对特征进行过滤"""
        field_value = features.get(rule['field'])
        if field_value is None:
            return False # 如果特征不存在,则过滤掉

        op, val = rule['operator'], rule['value']
        if op == '>=': return field_value >= val
        if op == '<=': return field_value <= val
        if op == 'between': return val[0] <= field_value <= val[1]
        return True

    def score(self, features: Dict, debug: bool = False) -> Any:
        """根据评分规则计算节点的总分。如果 debug=True,则返回分数和详情。"""
        score = 0.0
        score_details = {}
        for sc in self.cfg['scorers']:
            feature_val = features.get(sc['field'])
            if feature_val is not None:
                component_score = feature_val * sc['weight']
                score += component_score
                if debug:
                    score_details[sc['name']] = round(component_score, 2)
        
        if debug:
            return round(score, 2), score_details
        return score