wenmo8 发布的文章

REDISANT 提供互联网与物联网开发测试套件
#


第七章:Modbus 总结
#

Modbus 是工业通信设备中最常用的协议之一,网关向 Modbus 设备发送命令以获取设备的值。

在 Modbus 网络上的所有设备中,通常只有一个主设备,它会向其他从设备发送不同的命令。Modbus 主设备会向网络上的所有设备广播消息,但只有分配了 Modbus 地址来接受命令的设备才会接受该命令,其余设备将拒绝该命令。基本的 Modbus 命令可以指示 RTU 更改其某个寄存器中的值、控制或读取 I/O 端口,以及命令设备发回其寄存器中包含的一个或多个值。

在工业环境中使用 Modbus 的主要原因是:

  • 它是专为工业应用而开发的
  • 它是公开发布的,免版税
  • 它易于部署和维护
  • 它移动原始位或字,而不会对供应商施加太多限制
  • 在 Modbus 协议上轻松获得 SCADA 系统

Modbus 协议有多种类型:

  • Modbus TCP
  • Modbus RTU
  • Modbus ASCII
  • Modbus Plus
  • Modbus Daniels
  • Modbus Tek-Air
  • Modbus Omniflow

其中最流行的是 Modbus RTU 和 Modbus TCP/IP。

ModbusRTU 是一种串行通信协议,可连接同一网络上的不同设备,并使它们之间可以进行通信。

ModbusTCP 涵盖使用 TCP/IP 协议通过“内部网”或“互联网”环境进行 Modbus 通信。目前,该协议最常见的用途是将 PLC、I/O 模块和网关通过以太网连接到其他简单现场总线或 I/O 网络。

总会有这样一个问题:为什么使用面向连接的 TCP/IP 协议而不是面向数据报的 UDP。主要原因是通过将单个“通信”隔离在可以识别、取消和监督的连接中来保持对单个“通信”的控制,而无需对客户端和服务器应用程序执行特定操作。这使该机制能够容忍网络性能变化,并且还提供了添加防火墙和代理等安全功能的空间。

MODBUS/TCP/IP 处理两种不同的情况。在协议层面上,连接很容易被识别。单个连接可用于执行多个独立通信。此外,TCP/IP 允许大量并发连接,因此用户决定重新使用旧连接或重新连接到常用连接。

MODBUS 内存模型/MODBUS 存储模型
#

MODBUS 具有独特的寻址模式。Modbus 设备会将其中的每个值存储在特定地址。例如,功率计将仅在 Modbus 地址 40001 处存储 Volt A-N 值。

有四种 Modbus 数据类型:

Modbus 数据类型 数据格式和通用名称 地址起始位置
线圈状态 位,二进制值 00001 此类数据可由 I/O 系统提供。
离散输入状态 二进制值 10001 此类型数据可由应用层更改。
输入寄存器 二进制值 30001 此类数据可由 I/O 系统提供。
保持寄存器 模拟值 40001 此类数据可由应用层更改。

MODBUS RTU 如何工作?
#

  • Modbus通过设备之间的串行线路传输。最简单的设置是使用一条串行电缆连接两个设备(主设备和从设备)上的串行端口。
  • 数据以一系列 1 和 0(称为位)的形式发送。每个位都以电压的形式发送。0以正电压的形式发送,1以负电压的形式发送。位发送得非常快。典型的传输速度为 9600 波特(每秒位数)。

Modbus TCP 如何工作?
#

可以使用网关上的以太网端口连接 Modbus 设备。我们可以使用任何标准 Modbus 扫描仪进行查询,以从 Modbus 设备中提取值。所有请求都通过注册端口 502 上的 TCP/IP 发送。

Modbus RTU、TCP 和 ASCII 之间有什么区别?
#

Modbus 协议定义了一个独立于底层通信层的协议数据单元 (PDU)。 Modbus RTU 是最常用的,它是 PDU 的二进制表示,在 PDU 之前有寻址,末尾附加有 CRC。Modbus ASCII 是使用所有可打印字符(通常是两倍的字节数)表示的相同 PDU。Modbus TCP 本质上与 Modbus RTU 完全相同,只是 CRC 不在应用层字节字符串中,而留给 TCP 层自动处理。RTU 数据包的 TCP 封装中还有一些额外的寻址字节。

无论协议变体如何,功能代码、寄存器编号和寻址都是相同的。寄存器类型相同,即为 Modbus 设备定义了相同的数据块。

OSI 模型上的 MODBUS 协议
#

标准 Modbus 中如何存储数据?
#

  • 从设备中的信息存储在四个不同的表中。
  • 每个表有 9999 个数据点,可以存储不同的值。

每个线圈有 1 位,并给出 x0000 和 x9999 之间的数据地址。

每个寄存器是 1 个字 = 16 位 = 2 个字节,并且数据地址在 x0000 和 x9999 之间。

数字40001、10000等应作为位置的地址。这些数字永远不会显示在实际消息中。实际地址将是存储数字的偏移量。因此,如果设备具有 Modbus 的 ADU(应用程序定义单元)寻址,则地址 0001 将位于偏移量 40001 处,而对于 PDU(协议定义单元),它将位于偏移量 40002 处。

Modbus 中的从属 ID 或设备 ID
#

网络中的每个设备都必须分配一个唯一的单元地址,该地址可以介于 1 和 255 之间。当主设备请求数据时,它发送的第一个字节是设备/从属地址。因此,只有在发送第一个字节后,从属设备才会决定响应或忽略。

Modbus 映射
#

Modbus 映射是支持 Modbus 作为通信协议的任何特定设备的简单点列表。

Modbus 映射应包含以下信息:

  • 设备读取和存储什么类型的数据(例如温度或压力)
  • 数据存储在哪个地址(例如 40001 处的电压 A-N)
  • 数据存储的格式是什么(例如位、UINT16、SINT16 等)
  • 如果需要,提供点的工程单位。
  • 有关设备是否具有 ADU 或 PDU 寻址的信息。

大多数设备都预装了 Modbus 寄存器。在某些情况下,制造商还允许操作员根据自己的要求配置设备。

MODBUS 错误检查
#

MODBUS 网络采用两种错误检查方法:奇偶校验

  • 数据字符帧的奇偶校验(偶校验、奇校验或无奇偶校验)
  • 消息帧内的帧校验(RTU 模式下的循环冗余校验或 ASCII 模式下的纵向冗余校验)。

奇偶校验
#

MODBUS 设备可以配置为偶校验或奇校验,或无奇偶校验。这决定了字符数据帧的奇偶校验位的设置方式。如果选择偶校验或奇校验,则计算每个字符帧数据部分中的 1 位数量。RTU 模式下的每个字符包含 8 位。然后,奇偶校验位将设置为 0 或 1,从而导致 1 位总数为偶数(偶校验)或奇数(奇校验)。

帧检查
#

LRC 纵向冗余校验(仅限 ASCII 模式)在 ASCII 传输模式下,字符帧包含一个 LRC 字段,作为 CRLF 字符之前的最后一个字段。此字段包含两个 ASCII 字符,表示除起始冒号字符和结束 CR LF 字符对之外的所有字段的纵向冗余计算结果。

CRC 错误检查(仅限 RTU 模式)
#

RTU 模式消息帧包括一种基于循环冗余校验 (CRC) 的错误检查方法。消息帧的错误检查字段包含一个 16 位值(两个 8 位字节),其中包含对消息内容执行的循环冗余校验 (CRC) 计算的结果。

Modbus 定义的功能代码:
#

主设备发送的第二个字节是功能代码。此数字告诉从设备要访问哪个表以及是否从表中读取或写入。

功能代码 操作 表名称
01 (十六进制 01) 读取 离散输出线圈
05 (十六进制 05) 写入单个 离散输出线圈
15 (十六进制 0F) 写入多个 离散输出线圈
02 (十六进制 02) 读取 离散输入触点
04 (十六进制 04) 读取 模拟输入寄存器
03 (十六进制 03) 读取 模拟输出保持寄存器
06 (十六进制 06) 写入单个 模拟输出保持寄存器
16 (十六进制 10) 写入多个 模拟输出保持寄存器

Modbus 异常代码
#

代码 名称 含义
01 非法功能 查询中收到的功能代码不是服务器(或从属设备)允许的操作。这可能是因为功能代码仅适用于较新的设备,并且未在所选单元中实现。它也可能表明服务器(或从属设备)处于错误状态,无法处理此类请求,例如因为它未配置并被要求返回寄存器值。
02 非法数据地址 查询中收到的数据地址不是服务器(或从属设备)允许的地址。更具体地说,参考编号和传输长度的组合无效。对于具有 100 个寄存器的控制器,PDU 将第一个寄存器的地址设为 0,将最后一个寄存器的地址设为 99。如果提交的请求的起始寄存器地址为 96,寄存器数量为 4,则该请求将成功(至少在地址方面)对寄存器 96、97、98、99 进行操作。如果提交的请求的起始寄存器地址为 96,寄存器数量为 5,则该请求将失败,并出现异常代码 0x02“非法数据地址”,因为它尝试对寄存器 96、97、98、99 和 100 进行操作,而没有地址为 100 的寄存器。
03 非法数据值 查询数据字段中包含的值不是服务器(或从属设备)允许的值。这表明复杂请求的其余部分的结构存在错误,例如隐含的长度不正确。它具体并不意味着提交存储在寄存器中的数据项具有超出应用程序预期的值,因为 MODBUS 协议不知道任何特定寄存器的任何特定值的意义。
04 从设备故障 当服务器(或从设备)尝试执行请求的操作时发生不可恢复的错误。
05 确认 专门与编程命令结合使用。服务器(或从设备)已接受请求并正在处理它,但这样做需要很长时间。返回此响应是为了防止客户端(或主设备)中发生超时错误。客户端(或主设备)接下来可以发出轮询程序完成消息来确定处理是否完成。
06 从设备忙 专门与编程命令结合使用。服务器(或从设备)正在处理长时间的程序命令。客户端(或主设备)应在服务器(或从设备)空闲时重新传输该消息。
08 内存奇偶校验错误 专门与功能代码 20 和 21 以及引用类型 6 结合使用,表示扩展文件区域未能通过一致性检查。服务器(或从设备)尝试读取记录文件,但检测到内存中的奇偶校验错误。客户端(或主设备)可以重试该请求,但服务器(或从设备)设备上可能需要服务。
0A 网关路径不可用 专门与网关结合使用,表示网关无法分配从输入端口到输出端口的内部通信路径来处理请求。通常意味着网关配置不正确或过载。
0B 网关目标设备响应失败 专门与网关结合使用,表示未从目标设备获得响应。通常意味着该设备不在网络上。

版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。 原文链接:
https://blog.redisant.cn

题目:请你来实现一个 myAtoi(string s) 函数,使其能将字符串转换成一个 32 位有符号整数。

01
、手动处理每个字符法

最简单的方法永远是脑海中第一个想到的方法,也是最暴力的方法,而这一题我们只需要安装题目要求一个一个字符处理即可,其实整个解法相当简单。

我们梳理一下解题思路,大致可分为以下三步:

(1)处理开头空格,通过while循环把开头的所有空格都去除掉;

(2)处理正负符号,判断是否包含+/-号,并做下标记;

(3)处理数字,首先判断当前字符是否为数值,如果是则计算出其值,并且检测是否溢出,在未溢出的情况下,通过x*10+digit的方式累计结果;

(4)最后根据正负号返回正确结果。

具体代码如下:

//解法 1:手动处理每个字符(经典解法)
public static int MyAtoi1(string s)
{
    //结果
    var result = 0;
    //当前处理字符索引
    var index = 0;
    //标记正负数
    var sign = 1;
    //字符串长度
    var length = s.Length;
    //去除开头的空格
    while (index < length && s[index] == ' ')
    {
        //处理下一个字符
        index++;
    }
    //处理正负符号
    if (index < length && (s[index] == '+' || s[index] == '-'))
    {
        //标记正负数
        sign = s[index] == '-' ? -1 : 1;
        //处理下一个字符
        index++;
    }
    //转换数字字符为数字
    while (index < length && char.IsDigit(s[index]))
    {
        //计算当前字符数值
        var digit = s[index] - '0';
        //检查是否溢出
        if (result > (int.MaxValue - digit) / 10)
        {
            return sign == 1 ? int.MaxValue : int.MinValue;
        }
        //累积当前字符至结果中
        result = result * 10 + digit;
        //处理下一个字符
        index++;
    }
    //返回结果
    return sign * result;
}

02
、正则表达式法

还有一种比较简洁的方式,可以直接通过正则表达式匹配出满足条件的字符,然后再通过BigInteger.TryParse进行类型转换,之所以选择BigInteger类型是因为其他值类型都可能导致溢出情况。

根据上一题的解题思路,我们来尝试一步一步用正则表达式实现。

首先需要匹配开头的空白字符。

^:表示一个锚点,匹配字符串的开头;

\s:表示特殊字符,匹配空白字符,包括空格、制表符、换行符等;

*:表示量词,表示其前面元素可以出现零次或多次;

因此可以通过^\s*来实现字符串开头的空格处理。

[]:定义一个字符类,表示匹配方括号中的任意一个字符;

+-:表示加号和减号,即正数和负数符号;

?:表示量词,表示其前面元素可以出现零次或一次;

因此可以通过[+-]?来实现匹配正负号。

\d:表示匹配一个数字字符,范围为0-9;

+:表示量词,表示其前面元素可以出现一次或多次;

因此可以通过\d+来实现连续数字字符匹配。

下面看看具体实现代码:

//解法 2:正则表达式法
public static int MyAtoi2(string s)
{
    //使用正则表达式匹配符合要求的部分
    //^\s*:表示匹配字符串开头的零个或多个空白字符(空格、制表符等)。
    //[+-]?:表示符号(+ 或 -)可选。
    //\d+:表示一个或多个数字。
    var match = System.Text.RegularExpressions.Regex.Match(s, @"^\s*[+-]?\d+");
    //匹配成功,并且可以转换为数值
    if (match.Success && BigInteger.TryParse(match.Value, out var result))
    {
        //大于int最大值
        if (result > int.MaxValue)
        {
            return int.MaxValue;
        }
        //小于int最小值
        if (result < int.MinValue)
        {
            return int.MinValue;
        }
        //返回结果
        return (int)result;
    }
    return 0;
}

03
、状态机法

此题还有一种经典解法,即状态机法,状态机的核心思想是在一组有限的状态中,并通过输入触发状态之间的转移。

我们以此题为例,假设在处理字符串的过程中,一直存在一个状态state,而每次处理的当前字符则可以触发当状态state转移到下一个状态state1,如此我们只需要枚举出所有state和当前字符关于state1的映射关系即可。

我们可以建立如下图所示状态机:

如上图,如果当前state为“开始”,并且当前字符为“空格”,则state1为“开始”,如果当前字符为“数字”,则state1为“处理数字”,以此类推,而state1则会绝对当前字符具体的处理方法。

我们也可以用以下表格来表示这个状态机:

有了状态机状态关系映射表,我们就可以进行代码实现了,其逻辑也很简单,大致分为以下四步:

(1)构建状态机状态表;

(2)获取当前字符对应状态;

(3)通过状态转移确定当前字符处理逻辑;

(4)对要处理的字符串进行遍历处理得到最终结果;

具体实现代码如下:

//解法 3:状态机法
public int MyAtoi3(string s)
{
    Automaton automaton = new Automaton();
    return automaton.Atoi(s);
}
public class Automaton
{
    //0:"开始"状态
    private const int Start = 0;
    //1:"标记符号"状态
    private const int Signed = 1;
    //2:"处理数字"状态
    private const int InNumber = 2;
    //3:"结束"状态
    private const int End = 3;
    //符号:1为正数,0为负数
    private int _sign = 1;
    //数值结果
    private long _answer = 0;
    //记录当前处理状态
    private int _state = Start;
    //状态表
    private readonly Dictionary<int, int[]> _table = new Dictionary<int, int[]>()
    {
        {Start,new int[]{ Start, Signed, InNumber, End}},
        {Signed,new int[]{ End, End, InNumber, End}},
        {InNumber,new int[]{ End, End, InNumber, End}},
        {End,new int[]{ End, End, End, End}},
    };
    //处理当前字符
    private void Handle(char c)
    {
        //获取当前状态
        var currentState = GetState(c);
        //转移状态
        _state = _table[_state][currentState];
        switch (_state)
        {
            //处理数字
            case InNumber:
                _answer = _answer * 10 + c - '0';
                //溢出判断
                _answer = _sign == 1 ? Math.Min(_answer, int.MaxValue) : Math.Min(_answer, -(long)int.MinValue);
                break;
            //处理正负号
            case Signed:
                _sign = c == '+' ? 1 : -1;
                break;
            case Start:
            case End:
                break;
        }
    }
    //获取当前字符对应状态
    private static int GetState(char c)
    {
        //空格
        if (char.IsWhiteSpace(c))
        {
            return Start;
        }
        //正负号
        if (c == '+' || c == '-')
        {
            return Signed;
        }
        //数字
        if (char.IsDigit(c))
        {
            return InNumber;
        }
        //其他
        return End;
    }
    //字符串转换为整数
    public int Atoi(string s)
    {
        var length = s.Length;
        for (int i = 0; i < length; ++i)
        {
            Handle(s[i]);
        }
        return (int)(_sign * _answer);
    }
}


:测试方法代码以及示例源码都已经上传至代码库,有兴趣的可以看看。
https://gitee.com/hugogoos/Planner

服务端如何验证客户端已经登录?

在用户成功登录后,服务端会发放一个凭证。之后,客户端的每次请求都需要携带该凭证,服务端通过验证凭证的有效性来判断用户是否已登录,并处理请求。

以下是
Session

JWT
在这方面的不同之处:

1. 凭证的内容是什么?

  • Session
    :凭证是一个简单的
    ID 字符串
    ,用于映射服务端存储的会话信息。
JSESSIONID=8C3C44A3A0B522F1B93D3F8C4F17F2E7; Path=/your-web-app; HttpOnly
  • JWT
    :凭证是一个
    自包含
    的令牌,解码后可以直接获得会话信息(如用户信息、权限等)。
//JWT token格式
<Header>.<Payload>.<Signature>

//JWT 案例Token
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.S5rf1jOSGbHkGBv1buVpzCHYtoJXnJkK9J9M2yExhms//JWT 案例token 各部分解码
{"alg":"HS256","typ":"JWT"}.{"userId":"1234567890","name":"John Doe","iat":1516239022}.S5rf1jOSGbHkGBv1buVpzCHYtoJXnJkK9J9M2yExhms//注意:JWT并不是加密数据,只是提供了签名防止内容被篡改。Header和Payload都是明文。

2. 凭证如何传输?

  • Session
    :服务端通过
    Set-Cookie
    方式将凭证发送给客户端,客户端将凭证存储在 cookie 中,并在每次请求中通过
    Cookie
    头部将其发送回服务端。
  • JWT
    :服务端将凭证返回给客户端,客户端存储在
    localStorage

    sessionStorage
    中。后续请求中,客户端通过
    Authorization
    头部将 JWT 传递给服务端。

3. 服务端如何检测凭证?

  • Session
    :服务端根据接收到的
    sessionId
    查询会话信息,若会话存在则验证通过,服务端继续处理请求。
  • JWT
    :服务端验证 JWT 中的签名,签名通过表明凭证未被篡改,且确实是由服务端签发的。(二开应用记得修改JWT签名密钥,否则其他系统的JWT也能访问你的系统)

4. 认证信息存储在哪里?

  • Session
    :认证信息存储在服务端内存中,或存储在共享的内存系统(如 Redis)中。
  • JWT
    :认证信息存储在客户端的凭证中,JWT 令牌本身包含了所有认证信息。


关于水平扩展

Session 的缺点

当系统只有单个服务时,会话信息可以存储在应用内部。但一旦服务拆分为多个节点,会话信息需要迁移到共享存储(如 Redis)。此时,所有服务的请求都必须访问 Redis。如果 Redis 故障或宕机,服务将无法正常工作,直到 Redis 恢复。

JWT 的优势

JWT 的一个主要优势是其
自包含
特性,所有认证信息都存储在客户端的凭证中。由于 JWT 的签名是通过服务端的密钥进行验证,服务端无需访问共享存储系统(如 Redis),每个服务节点都可以独立地验证凭证。这使得 JWT 在分布式架构中更具优势,避免了 Redis 故障时的单点风险。


关于会话控制

Session 的优点

由于会话信息存储在服务端,服务端可以方便地管理会话。例如,若需要
踢人
,可以直接删除 Redis 或内存中的会话信息,之后客户端的请求会被拒绝,用户需重新登录。

JWT 的缺点

由于 JWT 在客户端存储,服务端无法直接删除客户端的凭证。如果需要实现踢人操作,服务端必须记录用户签发的 token ID,并通过
黑名单
来阻止客户端使用已失效的凭证。

有时候有人担心使用黑名单会与 Session 的方式相似,仍然依赖于一个中心数据存储。但可以通过以下方式解决:

  • 中心数据(Redis)存储用户签发的情况,并将黑名单推送到各个应用。
  • 每个应用本地保存黑名单副本,这样就不需要频繁查询中心存储。

此外,JWT 有过期时间,黑名单可以设置相同的过期时间,避免过多的数据存储。并且,踢人操作并不频繁,所以黑名单的数据量不会很大。

如果不需要“立即”封禁 JWT,可以考虑使用
短生命周期的 JWT
结合
刷新机制
。这种方式也非常常见。即服务端返回两个 token:

  • access_token
    (JWT)
  • refresh_token
    (通常是一个 UUID,服务端保存该 key 到 Redis 中,value 为用户信息)

客户端使用
refresh_token
生成新的
access_token

{"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.S5rf1jOSGbHkGBv1buVpzCHYtoJXnJkK9J9M2yExhms"
    "refresh_token": "c0cdf579-54d5-46fc-8e1b-bd42bd01b556"}


关于会话存储的内容

  • Session
    :由于会话信息存储在服务端,服务端可以在会话中存储一些经常访问的数据。
  • JWT
    :JWT 的会话内容是
    明文
    的,虽然签名可以防止篡改,但依然可能被读取,因此敏感数据不应放在 JWT 中。另外,JWT 是每次请求都携带的,数据过大会占用带宽,因此不适合存储过多的信息。


关于页面路由控制

  • JWT
    非常适合
    SPA
    (单页面应用),因为页面路由控制通常由前端负责,后端仅通过 JWT 验证数据访问权限。

  • 对于
    MPA
    (多页面应用),使用 JWT 控制页面访问时会遇到困难。因为在浏览器行为(如页面刷新)中,JWT 需要通过
    Authorization
    头部传递,但刷新页面时,浏览器行为不能直接携带该信息。

解决方案:后端可以在用户登录时不仅在
body
中返回 JWT,还通过
Set-Cookie
传递 token。这样客户端请求时会自动携带 JWT,后端校验的时候可以从
Cookie

Authorization
头部两个数据源获取 token。


总结

  • Session
    :将会话信息存储在服务端,凭证是会话 ID,适用于单一应用场景或需要共享内存(如 Redis)的环境,但依赖于共享存储系统。
  • JWT
    :凭证包含所有认证信息,并通过签名确保其完整性,适用于分布式架构,且无需依赖外部存储系统。

在拆分为多个服务的分布式系统中,JWT 通过自证机制避免了单点故障的风险,而 Session 则需要一个可靠的存储系统来共享会话信息。具体选择哪种方式,应根据系统的规模、需求和容错能力来决定。

1.
背景

这段时间项目比较忙,所以本
qiang~
有些耽误了学习,不过也算是百忙之中,抽取时间来支撑一个读者的需求,即爬取一些财经网站的新闻并自动聚合。

该读者看了之前的《
AI
资讯的自动聚合及报告生成

》文章后,想要将这一套流程嵌套在财经领域,因此满打满算耗费了
2-3
天时间,来完成了该需求。

注意:爬虫不是本人的强项,只是一丢丢兴趣而已
;
其次,本篇文章主要是用于个人学习,客官们请勿直接商业使用。

2.
面临的难点

1.
爬虫框架选取
:
采用之前现学现用的
crawl4ai
作为基础框架,使用其高阶技能来逼近模拟人访问浏览器,因为网站都存在反爬机制,如鉴权、
cookie
等;

2.
外网新闻
:
需要kexue上网;

3.
新闻内容解析
:
此处耗费的工作量最多,并不是
html
的页面解析有多难,主要是动态页面加载如何集成
crawl4ai
来实现,且每个新闻网站五花八门。

3.
数据源

数据源

url

备注


lian

https://www.cls.cn/depth?id=1000

https://www.cls.cn/depth?id=1003

https://www.cls.cn/depth?id=100
7

1000:
头条
,

1003: A

,

1007:
环球


huang

https://finance.ifeng.com/shanklist/1-64-/


lang

https://finance.sina.com.cn/roll/#pageid=384&lid=2519&k=&num=50&page=1

https://finance.sina.com.cn/roll/#pageid=384&lid=2672&k=&num=50&page=1

2519:
财经

2672
:
美股


qiu
时报

https://finance.huanqiu.com

zaobao

https://www.zaobao.com/finance/china

https://www.zaobao.com/finance/world

国内及世界

fox

https://www.foxnews.com/category/us/economy

https://www.foxnews.com//world/global-economy

美国及世界

cnn

https://edition.cnn.com/business

https://edition.cnn.com/business/china

国内及世界

reuters

https://www.reuters.com/business

4.
部分源码

为了减少风险,本
qiang~
只列出财
lian
社网页的解析代码,读者如想进一步交流沟通,可私信联系。

代码片段解析
:

1. schema
是以
json
格式叠加
css
样式的策略,
crawl4ai
基于
schema
可以实现特定元素的结构化解析

2. js_commands

js
代码,主要用于模拟浏览新闻时的下翻页

importasynciofrom crawl4ai importAsyncWebCrawlerfrom crawl4ai.extraction_strategy importJsonCssExtractionStrategyimportjsonfrom typing importDict, Any, Union, Listimportosimportdatetimeimportreimporthashlibdefmd5(text):
m
=hashlib.md5()
m.update(text.encode(
'utf-8'))returnm.hexdigest()def get_datas(file_path, json_flag=True, all_flag=False, mode='r'):"""读取文本文件"""results=[]

with open(file_path, mode, encoding
='utf-8') as f:for line inf.readlines():ifjson_flag:
results.append(json.loads(line))
else:
results.append(line.strip())
ifall_flag:ifjson_flag:return json.loads(''.join(results))else:return '\n'.join(results)returnresultsdef save_datas(file_path, datas, json_flag=True, all_flag=False, with_indent=False, mode='w'):"""保存文本文件"""with open(file_path, mode, encoding='utf-8') as f:ifall_flag:ifjson_flag:
f.write(json.dumps(datas, ensure_ascii
=False, indent= 4 if with_indent elseNone))else:
f.write(
''.join(datas))else:for data indatas:ifjson_flag:
f.write(json.dumps(data, ensure_ascii
=False) + '\n')else:
f.write(data
+ '\n')classAbstractAICrawler():def __init__(self) ->None:pass defcrawl():raiseNotImplementedError()classAINewsCrawler(AbstractAICrawler):def __init__(self, domain) ->None:
super().
__init__()
self.domain
=domain
self.file_path
= f'data/{self.domain}.json'self.history=self.init()definit(self):if notos.path.exists(self.file_path):return{}return {ele['id']: ele for ele inget_datas(self.file_path)}defsave(self, datas: Union[List, Dict]):ifisinstance(datas, dict):
datas
=[datas]
self.history.update({ele[
'id']: ele for ele indatas})
save_datas(self.file_path, datas
=list(self.history.values()))

async
defcrawl(self, url:str,
schema: Dict[str, Any]
=None,
always_by_pass_cache
=True,
bypass_cache
=True,
headless
=True,
verbose
=False,
magic
=True,
page_timeout
=15000,
delay_before_return_html
=2.0,
wait_for
='',
js_code
=None,
js_only
=False,
screenshot
=False,
headers
={}):

extraction_strategy
= JsonCssExtractionStrategy(schema, verbose=verbose) if schema elseNone

async with AsyncWebCrawler(verbose
=verbose,
headless
=headless,
always_by_pass_cache
=always_by_pass_cache, headers=headers) as crawler:
result
=await crawler.arun(
url
=url,
extraction_strategy
=extraction_strategy,
bypass_cache
=bypass_cache,
page_timeout
=page_timeout,
delay_before_return_html
=delay_before_return_html,
wait_for
=wait_for,
js_code
=js_code,
magic
=magic,
remove_overlay_elements
=True,
process_iframes
=True,
exclude_external_links
=True,
js_only
=js_only,
screenshot
=screenshot
)
assert result.success, "Failed to crawl the page" ifschema:
res
=json.loads(result.extracted_content)ifscreenshot:returnres, result.screenshotreturnresreturnresult.htmlclassFinanceNewsCrawler(AINewsCrawler):def __init__(self, domain='') ->None:
super().
__init__(domain)defsave(self, datas: Union[List, Dict]):ifisinstance(datas, dict):
datas
=[datas]
self.history.update({ele[
'id']: ele for ele indatas})
save_datas(self.file_path, datas
=datas, mode='a')

async
defget_last_day_data(self):
last_day
= (datetime.date.today() - datetime.timedelta(days=1)).strftime('%Y-%m-%d')
datas
=self.init()return [v for v in datas.values() if last_day in v['date']]classCLSCrawler(FinanceNewsCrawler):"""财某社新闻抓取""" def __init__(self) ->None:
self.domain
= 'cls'super().__init__(self.domain)
self.url
= 'https://www.cls.cn'asyncdef crawl_url_list(self, url='https://www.cls.cn/depth?id=1000'):
schema
={'name': 'caijingwang toutiao page crawler','baseSelector': 'div.f-l.content-left','fields': [
{
'name': 'top_titles','selector': 'div.depth-top-article-list','type': 'nested_list','fields': [
{
'name': 'href', 'type': 'attribute', 'attribute':'href', 'selector': 'a[href]'}
]
},
{
'name': 'sec_titles','selector': 'div.depth-top-article-list li.f-l','type': 'nested_list','fields': [
{
'name': 'href', 'type': 'attribute', 'attribute':'href', 'selector': 'a[href]'}
]
},
{
'name': 'bottom_titles','selector': 'div.b-t-1 div.clearfix','type': 'nested_list','fields': [
{
'name': 'href', 'type': 'attribute', 'attribute':'href', 'selector': 'a[href]'}
]
}
]
}

js_commands
=["""(async () => {{

await new Promise(resolve => setTimeout(resolve, 500));

const targetItemCount = 100;

let currentItemCount = document.querySelectorAll('div.b-t-1 div.clearfix a.f-w-b').length;
let loadMoreButton = document.querySelector('.list-more-button.more-button');

while (currentItemCount < targetItemCount) {{
window.scrollTo(0, document.body.scrollHeight);

await new Promise(resolve => setTimeout(resolve, 1000));

if (loadMoreButton) {
loadMoreButton.click();
} else {
console.log('没有找到加载更多按钮');
break;
}

await new Promise(resolve => setTimeout(resolve, 1000));

currentItemCount = document.querySelectorAll('div.b-t-1 div.clearfix a.f-w-b').length;

loadMoreButton = document.querySelector('.list-more-button.more-button');
}}
console.log(`已加载 ${currentItemCount} 个item`);
return currentItemCount;
}})();
"""]
wait_for
= ''results={}

menu_dict
={'1000': '头条','1003': 'A股','1007': '环球'}for k, v inmenu_dict.items():
url
= f'https://www.cls.cn/depth?id={k}' try:
links
= await super().crawl(url, schema, always_by_pass_cache=True, bypass_cache=True, js_code=js_commands, wait_for=wait_for, js_only=False)exceptException as e:print(f'error {url}')
links
=[]iflinks:
links
= [ele['href'] for eles in links[0].values() for ele in eles if 'href' inele]
links
= sorted(list(set(links)), key=lambdax: x)
results.update({f
'{self.url}{ele}': v for ele inlinks})returnresults

async
defcrawl_newsletter(self, url, category):
schema
={'name': '财联社新闻详情页','baseSelector': 'div.f-l.content-left','fields': [
{
'name': 'title','selector': 'span.detail-title-content','type': 'text'},
{
'name': 'time','selector': 'div.m-r-10','type': 'text'},
{
'name': 'abstract','selector': 'pre.detail-brief','type': 'text','fields': [
{
'name': 'href', 'type': 'attribute', 'attribute':'href', 'selector': 'a[href]'}
]
},
{
'name': 'contents','selector': 'div.detail-content p','type': 'list','fields': [
{
'name': 'content', 'type': 'text'}
]
},
{
'name': 'read_number','selector': 'div.detail-option-readnumber','type': 'text'}
]
}

wait_for
= 'div.detail-content' try:
results
= await super().crawl(url, schema, always_by_pass_cache=True, bypass_cache=True, wait_for=wait_for)
result
=results[0]exceptException as e:print(f'crawler error: {url}')return{}return{'title': result['title'],'abstract': result['abstract'],'date': result['time'],'link': url,'content': '\n'.join([ele['content'] for ele in result['contents'] if 'content' in ele and ele['content']]),'id': md5(url),'type': category,'read_number': await self.get_first_float_number(result['read_number'], r'[-+]?\d*\.\d+|\d+'),'time': datetime.datetime.now().strftime('%Y-%m-%d')
}

async
defget_first_float_number(self, text, pattern):
match
=re.search(pattern, text)ifmatch:return round(float(match.group()), 4)return0

async
defcrawl(self):
link_2_category
=await self.crawl_url_list()for link, category inlink_2_category.items():
_id
=md5(link)if _id inself.history:continuenews=await self.crawl_newsletter(link, category)ifnews:
self.save(news)
returnawait self.get_last_day_data()if __name__ == '__main__':
asyncio.run(CLSCrawler().crawl())

5.
总结

一句话足矣
~

开发了一款新闻资讯的自动聚合的工具,基于
crawl4ai
框架实现。

有问题可以私信或留言沟通!

6.
参考

(1)
Crawl4ai:
https://github.com/unclecode/crawl4ai

说明

该文章是属于OverallAuth2.0系列文章,每周更新一篇该系列文章(从0到1完成系统开发)。

该系统文章,我会尽量说的非常详细,做到不管新手、老手都能看懂。

说明:OverallAuth2.0 是一个简单、易懂、功能强大的权限+可视化流程管理系统。

友情提醒:本篇文章是属于系列文章,看该文章前,建议先看之前文章,可以更好理解项目结构。

qq群:801913255,进群有什么不懂的尽管问,群主都会耐心解答。

关注我,学不会你来打我

注意:该文章是理论篇,后面会有开发的全过程。
有兴趣的朋友,请关注我吧(*^▽^*)。

01 系统权限的划分

通常来说,一个系统权限的划分,主要分位2大类:功能级权限和数据级权限。那么它们是如何分类的,我们看下图。

从上图可以看出。功能级权限和数据级权限的区别在于它们对权限划分的颗粒度不同。
功能级权限:就像它名字一样,更多偏向于功能方面的权限控制。
数据级权限:更多的偏向于数据方面的权限控制。

02 权限的设计模式有哪些

了解了一个系统的权限划分后,那么我们要如何去规划,设计这些权限。尽量减少系统权限的维护成本和权限的耦合度。是开发人员一直面临的严峻问题。

据我的了解,目前比较流行的权限设计模式有以下四种。

通过上图,我们可以了解到权限的设计模式分为那四种,
那么接下来我们就用《小说》的形式
,分别讲一下这四种模式的使用场景及实现过程。

03 ACL基于用户的权限设计

乘风是一家创业公司的开发人员,由于是创业公司,规模不大,研发人员也只有可怜的3人,平时面临的研发任务非常紧张。
某天的傍晚,乘风狠狠地伸了个懒腰,环顾四周空荡荡的座位。正准备收拾东西回家的时候,老板叫住了他。
小乘,怎么财务的小红能看到运营的数据,可以让不同的人查看不同的数据嘛。
乘风的老板也不怎么懂技术,需求就说了一句:让不同的人员看到不同的数据。
乘风自信的回答道:可以的老板。
第二天,乘风就根据老板的需求基于ACL整理出了一套权限管理的方案。

以上就是乘风基于ACL设计的权限。可以看出菜单和人员直接挂钩,可以简单有效的做到每个员工访问不同的菜单,从而实现老板的需求。

04 RABC基于角色的权限设计

4.1角色权限

随着时间的推移,公司也处于快速发展阶段,终于在2年后的某一天。公司老板找到已经是研发组长的乘风说道:小乘啊,今天早上运营给我说,他们现在每天花在分配人员权限上的时间过多,每次有人员变动、人员加入都需要重新调整权限,而且不能成批量操作,大大降低了他们的工作效率。你看有什么办法解决这个问题没有。

乘风一听,毫不犹豫的说道。有的老板,明天我给你出一个方案。

老板一听很高兴,拍了拍乘风的肩膀离开了,他非常欣赏乘风这种办事风格,干净利落,总能解决问题。

乘风的自信来源于他一复一日的学习和专研。他明白用RABC就能完美解决老板的需求。于是他在第二天就做出了如下方案设计


上述2张图它的授权结果是一样的,但是很明显基于RABC的授权方式更加灵活。
原因在于乘风在人员和权限之间抽象出一个角色层,这样不仅能减少系统之间的耦合,还能大大降低运营人员的运维时间。

为什么这么说,举一个简单的例子:当【权限4】这块权限不想让任何人使用,那么在RABC中,我只需要把它从【角色3】中移除即可。反观ACL就比较麻烦,需要先后移除人员1和人员3的权限。试想一下这是3个员工,如果是10个100个,得有多麻烦。

很快乘风的方案得到了老板的肯定,并且给他升职加薪。

4.2 角色等级权限

这种设计模式很快就实现并用于系统之中,也确实大大解决了很多实际性问题。然而好景不长,公司在接下来的时间中,发展的越来越快,
规模越来越大。这种设计方式也出现了和ACL同样的问题。
当用户权限分的很细的时候,几乎每个用户都对应一个角色。
似乎又回到了几年前ACL设计模式的样子

于是乘风通过不断地对系统的分析和对技术的专研,希望能从其中得到有效的解决办法。

可能是日有所思,夜有所梦。在某一天的晚上,乘风在睡梦中得到了解决办法。
他梦见。。。
把之前设定的角色制定了等级。也就是说高等级的角色会继承低等级角色的所有权限。

得到答案的乘风,也不困了,来不及穿衣服裤子。就这样穿着一条大裤衩打开电脑,画出了如下设计方案。

你没看错,其实就是和组织机构类似,那么我们如何实现呢?请看下图

可以看到【角色1-2】它是继承了【角色1-2-1】的所有权限。根据这一原理,我们不用在为【角色1-2】单独在赋予权限3和权限4。

啊嚏,乘风摸了摸鼻子。一个喷嚏让他在意识到自己全身上下就只剩一条大裤衩。看着设计方案乘风淡淡的说了一句。

难道我真的是天才?随后就进入梦乡。

05 ABAC基于属性的权限设计

岁月悠悠,人生如梦,转眼间又过去了几年。乘风也不再年轻,不是当前刚进公司的小白,但也正是如此。他仿佛失去了当年对技术的向往和专研热情,因为他总觉得自己的技术已经处于很高的水平。直到有一天,老板又找到了他。

小乘,老板推开乘风的办公室,见乘风正在泡茶,眉头稍稍一皱,但随后很快就消失。

老板,乘风没有发现老板的异常,起身道

小乘,昨天我去财务部视察的时候发现,财务部的小李居然能看到我们公司所有员工的工资。按道理说除了财务经理有权限查看之外,财务部下面的人应该都不具备查看员工工资的权限。

还有人事部的小张,他怎么能随意查看公司领导层的人事资料,甚至能随意修改,这不是胡闹嘛?

你是研发部的负责人,你应该考虑考虑,系统的数据安全,那写人能看什么数据,能修改什么数据,要做到可调控。

接下来的时间中,乘风老板和他聊了很多。大多数都是围绕数据安全的问题展开。

对了,你的茶具还挺全,整的不错。走到门口的老板,忽然转身对乘风说道。

老板最后的话,让乘风陷入了深深的沉思之中。

回忆过去自己,从一开始的积极提升能力到如今的懈怠、自满,内心有一种说不出的感觉。

今天老板的话,让他意识到很多。
总结后的乘风,下定决心要找回自己,然后根据老板简述的需求,总结如下

1:不同的人需要查看不同的数据,做到可调控。

2:不同的人对数据有不同的操作权限,做到可调控。

这些问题的出现,让乘风很是头疼,不知从何下手。
于是他仿佛回到过去的自己,努力查阅资料,提升技能。

终于得到了一个有效的解决办法。那就是ABAC基于属性的权限设计
也就是说ABAC可以通过动态计算一个或者一组属性来进行权限控制。

那么ABAC属性大致分位哪些?

得到答案的乘风,马上着手设计ABAC基于属性的权限设计。很快就完成权限系统的迭代。

随着了解的越多,乘风也开始总结自身存在的问题。之前哪点对自己技术的自信,现在看来就是自负和自满。
技术没有顶峰,只有不断的求知、探索。

06 RABC+ABAC的权限设计

这个不必多说,就是字面的意思。
我们的OverallAuth2.0就使用RABC
+ABAC的
权限设计。

以上就是本篇文章的全部内容,感谢耐心观看

后端WebApi
预览地址:http://139.155.137.144:8880/swagger/index.html

前端vue 预览地址:http://139.155.137.144:8881

关注公众号:发送【权限】,获取前后端代码

有兴趣的朋友,请关注我微信公众号吧(*^▽^*)。

关注我:一个全栈多端的宝藏博主,定时分享技术文章,不定时分享开源项目。关注我,带你认识不一样的程序世界