东东
发布于 2024-12-18 / 3 阅读 / 0 评论 / 0 点赞

如何在系统中设计反爬虫

反爬虫

1、为了避免网站的数据被别人大规模盗取

2、为了避免频繁的爬虫请求导致系统过载

手段

可以参考这篇文章

总有坏人想爬我网站的数据,看我用这 10 - 编程导航 - 程序员编程学习交流社区

1、使用协议条款

2、限制请求条件

3、统计访问频率和封禁

4、多级处理策略+自动处理(警告、短期封禁、长期封禁)

5、自动告警+人工处理

。。。

如何通过统计访问频率来实现?

1、hotkey探测

统计访问频率的目的就是为了对访问的流量进行限制。

那么如果系统中已经引入了hotkey, 我们就可以使用热点key探测来实现。

比如当用户获取某个资源的时候,我们以业务内容+用户来做为key,并且在控制台给这个key配置一个热点规则

这样在业务中,就可以通过判断这个key是不是热点key,来分析当前请求是不是合法

2、sentinel限流

限流框架中提供了对热点参数进行限流配置的功能,我们可以将用户ID做为热点参数,如果当前用户的请求过多超过配置的规则

那么就可以在降级方法中进行限制。

3、本地计数器(单机)

前面两种方法都必须依赖第三方框架。如果项目本身没有引入,那么代价就比较大。

可以使用jdk自带的本地计数器来对需要反爬的资源请求进行统计。

请看示例

import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.LongAdder;

public class RequestRateLimiter {
    
    // 用于存储每个用户的访问次数计数器
    private ConcurrentHashMap<String, LongAdder> userRequestCounts = new ConcurrentHashMap<>();
    
    // 统计的时间间隔(比如每分钟重置一次计数)
    private final long interval;
    
    public RequestRateLimiter(long intervalInSeconds) {
        this.interval = intervalInSeconds;
        startResetTask();
    }

    /**
     * 每次用户访问时调用此方法
     * @param userId 用户的唯一标识符
     */
    public void recordRequest(String userId) {
        // 获取或者初始化用户的访问计数器
        userRequestCounts.computeIfAbsent(userId, key -> new LongAdder()).increment();
    }

    /**
     * 获取用户的当前访问次数
     * @param userId 用户的唯一标识符
     * @return 用户的访问次数
     */
    public long getRequestCount(String userId) {
        return userRequestCounts.getOrDefault(userId, new LongAdder()).sum();
    }

    /**
     * 定期重置每个用户的访问计数器
     */
    private void startResetTask() {
        // 定时任务,每隔指定的时间间隔重置计数
        new Thread(() -> {
            while (true) {
                try {
                    TimeUnit.SECONDS.sleep(interval);  // 等待指定的时间间隔
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                    return;
                }
                // 重置每个用户的计数器
                userRequestCounts.clear();
            }
        }).start();
    }
    
    public static void main(String[] args) {
        RequestRateLimiter limiter = new RequestRateLimiter(60);  // 每分钟重置一次

        // 模拟用户请求
        limiter.recordRequest("user1");
        limiter.recordRequest("user1");

        System.out.println("User1's current request count: " + limiter.getRequestCount("user1"));  // 输出 2
    }
}

4、基于redis进行统计(方案3的分布式版本)

使用redis,就是要定义一个key,然后每次请求来了,对key的value进行+1操作。

下面是示例

//假设使用 jedis 客户端
// 使用 Redis 的 INCR 操作增加当前秒的访问次数
jedis.incr(redisKey);

// 设置过期时间(TTL),例如只保存60秒的数据
jedis.expire(redisKey, 60);  // 60秒后自动过期

上面这种方法,因为是两步操作,且没有事务,不是原子性操作

当并发流量非常大的的时候,可能会出现计数异常

如果要在redis中实现原子操作,就要借助lua脚本

String luaScript = 
    "if redis.call('exists', KEYS[1]) == 1 then " +
    "  return redis.call('incr', KEYS[1]); " +
    "else " +
    "  redis.call('set', KEYS[1], 1); " +
    "  redis.call('expire', KEYS[1], 180); " +  // 设置 180 秒过期时间
    "  return 1; " +
    "end";

实际方法

public long incrAndGetCounter(String key, int timeInterval, TimeUnit unit, int expireTimeInSeconds){
        if (StrUtil.isBlank(key)){
            return 0;
        }
        // 根据时间粒度生成key
        long timeFactor;
        switch (unit) {
            case SECONDS:
                timeFactor = Instant.now().getEpochSecond() / timeInterval;
                break;
            case MINUTES:
                timeFactor = Instant.now().getEpochSecond() / (timeInterval * 60);
                break;
            case HOURS:
                timeFactor = Instant.now().getEpochSecond() / (timeInterval * 3600);
                break;
            default:
                throw new IllegalArgumentException("unsupported timeUnit");
        }

        String redisKey  = StrUtil.format("{}:{}", key, timeFactor);

        // lua脚本
        String luaScript =
                "if redis.call('exists', KEYS[1]) == 1 then " +
                        "return redis.call('incr', KEYS[1]); " +
                        "else " +
                        "redis.call('set', KEYS[1], 1); " +
                        "redis.call('expire', KEYS[1], ARGV[1]); " +
                        "return 1; " +
                        "end ";

        // 执行脚本
        RScript script = redisClient.getScript(IntegerCodec.INSTANCE);
        Object result = script.eval(
                RScript.Mode.READ_WRITE,
                luaScript,
                RScript.ReturnType.INTEGER,
                Collections.singletonList(redisKey),
                expireTimeInSeconds
        );
        return (long) result;
    }

有了检测方法之后,就是具体嵌入业务中

可以使用切面编程的方法,通过自定义注解,这样就可以在需要进行反爬虫的接口上添加注解,用来进行爬虫检测

示例如下:

自定义注解:

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface CrawlerDetect {

}

切面方法:

@Around("@annotation(crawlerDetect)")
    public Object around(ProceedingJoinPoint joinPoint, CrawlerDetect crawlerDetect) throws Throwable {
        RequestAttributes requestAttributes = RequestContextHolder.currentRequestAttributes();
        HttpServletRequest request = ((ServletRequestAttributes) requestAttributes).getRequest();
        // 当前登录用户
        User loginUser = userService.getLoginUser(request);
        // 爬虫检测
        crawlerDetectManager.crawlerDetect(loginUser.getId());

        // 通过权限校验,放行
        return joinPoint.proceed();
    }