ETJava Beta | Java    注册   登录
  • 搜索:
  • 《花100块做个摸鱼小网站! 》第七篇—谁访问了我们的网站?

    发表于      阅读(1)     博客类别:Crawler     转自:https://www.cnblogs.com/wlovet/p/18454074
    如有侵权 请联系我们删除  (页面底部联系我们)  

    ️基础链接导航️

    服务器 → ️ 阿里云活动地址

    看样例 → 摸鱼小网站地址

    学代码 → 源码库地址

    一、前言

    大家好呀,我是summo,最近发生了些事情(被裁员了,在找工作中)导致断更了,非常抱歉。刚被裁的时候还是有些难受,而且我还有房贷要还,有些压力,不过休息了一段时间,心态也平复了一些,打算一边找工作一边写文,如果有和我一样经历的同学,大家共勉!

    《花100块做个摸鱼小网站! 》这个系列的前六篇已经大概把整体的流程写完了,从这篇起我会补充一些细节和组件,让我们的小网站更加丰富一些。这一篇呢我会介绍如何将用户的访问记录留下来,看着自己做的网站被别人访问是一件很有意思和很有成就感的事情。

    对应的组件也就是我用红框标出来的那个,如下图:

    解释下PV和UV的意思,如下:

    • PV:页面访问量,即PageView,用户每次对网站的访问均被记录,用户对同一页面的多次访问,访问量累计。
    • UV:独立访问用户数:即UniqueVisitor,访问网站的一台电脑客户端为一个访客。

    二、用户身份标识

    用于表明一个用户身份最好的做法是做登录注册,但是一旦加了这样的逻辑,就会有很多麻烦的问题要处理,比如如何做人机验证啦、接口防刷啦,等等,这些问题不处理的话网站很容易被攻击。像我们这样的小网站,我觉得这个功能没必要,我们只需要知道有多少人访问过我们的网站就可以了。

    对于这样的需求,最简单的做法是根据用户的访问IP作为标识,然后根据IP解析一下地域信息,这样就已经很不错了。而目前最常用的IP解析工具就是:ip2region,如何使用这个组件我之前写过一篇文章进行介绍了,文章链接:SpringBoot整合ip2region实现使用ip监控用户访问城市

    核心代码是这段:

    package com.example.springbootip.util;
    
    
    import org.apache.commons.io.FileUtils;
    import org.lionsoul.ip2region.xdb.Searcher;
    
    import java.io.File;
    import java.text.MessageFormat;
    import java.util.Objects;
    
    public class AddressUtil {
    
        /**
         * 当前记录地址的本地DB
         */
        private static final String TEMP_FILE_DIR = "/home/admin/app/";
    
        /**
         * 根据IP地址查询登录来源
         *
         * @param ip
         * @return
         */
        public static String getCityInfo(String ip) {
            try {
                // 获取当前记录地址位置的文件
                String dbPath = Objects.requireNonNull(AddressUtil.class.getResource("/ip2region/ip2region.xdb")).getPath();
                File file = new File(dbPath);
                //如果当前文件不存在,则从缓存中复制一份
                if (!file.exists()) {
                    dbPath =    TEMP_FILE_DIR + "ip.db";
                    System.out.println(MessageFormat.format("当前目录为:[{0}]", dbPath));
                    file = new File(dbPath);
                    FileUtils.copyInputStreamToFile(Objects.requireNonNull(AddressUtil.class.getClassLoader().getResourceAsStream("classpath:ip2region/ip2region.xdb")), file);
                }
                //创建查询对象
                Searcher searcher = Searcher.newWithFileOnly(dbPath);
                //开始查询
                return searcher.searchByStr(ip);
            } catch (Exception e) {
                e.printStackTrace();
            }
            //默认返回空字符串
            return "";
        }
    
        public static void main(String[] args) {
            System.out.println(getCityInfo("1.2.3.4"));
        }
    }
    

    三、功能实现

    为了解耦逻辑,我使用了一个注解:@VisitLog,只要将该注解放在IndexController的index方法上即可。同时为了统计用户的访问数据,我们需要设计一张访问记录表将数据存下来,并设计一个小组件用来展示这些数据,具体流程如下文。

    1. 后端部分

    (1)访问记录表设计

    建表语句

    -- `summo-sbmy`.t_sbmy_visit_log definition
    
    CREATE TABLE `t_sbmy_visit_log` (
      `id` bigint(20) unsigned zerofill NOT NULL AUTO_INCREMENT COMMENT '物理主键',
      `device_type` varchar(256) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '设备类型,手机还是电脑',
      `ip` varchar(256) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '访问',
      `address` varchar(256) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT 'IP地址',
      `time` int DEFAULT NULL COMMENT '耗时',
      `method` varchar(2048) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '调用方法',
      `params` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci COMMENT '参数',
      `gmt_create` datetime DEFAULT NULL COMMENT '创建时间',
      `gmt_modified` datetime DEFAULT NULL COMMENT '更新时间',
      `creator_id` bigint DEFAULT NULL COMMENT '创建人',
      `modifier_id` bigint DEFAULT NULL COMMENT '更新人',
      PRIMARY KEY (`id`) USING BTREE
    ) ENGINE=InnoDB AUTO_INCREMENT=oDEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
    

    DO、Mapper、Repository等文件

    还记得我在第三篇介绍的那个DO生成插件吗,在config.properties改下表名和DO名,双击mybatis-generator:generate就可以生成对应的DO、Mapper、xml了。

    (2)VisitLog注解

    VisitLog.java

    package com.summo.sbmy.aspect;
    
    /**
     * 访问标识注解
     */
    public @interface VisitLog {
    }
    
    

    VisitLogAspect.java

    package com.summo.sbmy.aspect.visit;
    
    import java.io.Serializable;
    import java.lang.reflect.Method;
    import java.util.ArrayList;
    import java.util.LinkedHashMap;
    import java.util.List;
    import java.util.Map;
    import java.util.Set;
    
    import javax.servlet.http.HttpServletRequest;
    import javax.servlet.http.HttpServletResponse;
    
    import com.alibaba.fastjson.JSON;
    import com.alibaba.fastjson.JSONObject;
    
    import com.fasterxml.jackson.core.JsonProcessingException;
    import com.summo.sbmy.common.util.AddressUtil;
    import com.summo.sbmy.common.util.HttpContextUtil;
    import com.summo.sbmy.common.util.IpUtil;
    import com.summo.sbmy.dao.entity.SbmyVisitLogDO;
    import com.summo.sbmy.dao.repository.SbmyVisitLogRepository;
    import lombok.extern.slf4j.Slf4j;
    import org.aspectj.lang.ProceedingJoinPoint;
    import org.aspectj.lang.annotation.Around;
    import org.aspectj.lang.annotation.Aspect;
    import org.aspectj.lang.annotation.Pointcut;
    import org.aspectj.lang.reflect.MethodSignature;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.core.LocalVariableTableParameterNameDiscoverer;
    import org.springframework.stereotype.Component;
    import org.springframework.web.multipart.MultipartFile;
    
    import static com.summo.sbmy.common.util.DeviceUtil.isFromMobile;
    
    
    @Slf4j
    @Aspect
    @Component
    public class VisitLogAspect {
    
        @Autowired
        private SbmyVisitLogRepository sbmyVisitLogRepository;
    
        @Pointcut("@annotation(com.summo.sbmy.aspect.visit.Log)")
        public void pointcut() {
            // do nothing
        }
    
        @Around("pointcut()")
        public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
            //获取request
            HttpServletRequest request = HttpContextUtil.getHttpServletRequest();
            // 请求的类名
            MethodSignature signature = (MethodSignature)joinPoint.getSignature();
            Method method = signature.getMethod();
            String className = joinPoint.getTarget().getClass().getName();
            // 请求的方法名
            String methodName = signature.getName();
            String ip = IpUtil.getIpAddr(request);
            String address = AddressUtil.getAddress(ip);
            SbmyVisitLogDO sbmyVisitLogDO = SbmyVisitLogDO.builder().deviceType(isFromMobile(request) ? "手机" : "电脑").method(
                className + "." + methodName + "()").ip(ip).address(AddressUtil.getAddress(address)).build();
            // 请求的方法参数值
            Object[] args = joinPoint.getArgs();
            // 请求的方法参数名称
            LocalVariableTableParameterNameDiscoverer u = new LocalVariableTableParameterNameDiscoverer();
            String[] paramNames = u.getParameterNames(method);
            if (args != null && paramNames != null) {
                // 创建 key-value 映射用于生成 JSON 字符串
                Map<String, Object> paramMap = new LinkedHashMap<>();
                for (int i = 0; i < paramNames.length; i++) {
                    if (args[i] instanceof HttpServletRequest || args[i] instanceof HttpServletResponse) {
                        continue;
                    }
                    paramMap.put(paramNames[i], args[i]);
                }
                // 使用 Fastjson 将参数映射转换为 JSON 字符串
                String paramsJson = JSON.toJSONString(paramMap);
                sbmyVisitLogDO.setParams(paramsJson);
            }
            long beginTime = System.currentTimeMillis();
            Object proceed = joinPoint.proceed();
            long end = System.currentTimeMillis();
            sbmyVisitLogDO.setTime((int)(end - beginTime));
            sbmyVisitLogRepository.save(sbmyVisitLogDO);
            return proceed;
        }
    
        /**
         * 参数构造器¬
         *
         * @param params
         * @param args
         * @param paramNames
         * @return
         * @throws JsonProcessingException
         */
        private StringBuilder handleParams(StringBuilder params, Object[] args, List paramNames)
            throws JsonProcessingException {
            for (int i = 0; i < args.length; i++) {
                if (args[i] instanceof Map) {
                    Set set = ((Map)args[i]).keySet();
                    List<Object> list = new ArrayList<>();
                    List<Object> paramList = new ArrayList<>();
                    for (Object key : set) {
                        list.add(((Map)args[i]).get(key));
                        paramList.add(key);
                    }
                    return handleParams(params, list.toArray(), paramList);
                } else {
                    if (args[i] instanceof Serializable) {
                        Class<?> aClass = args[i].getClass();
                        try {
                            aClass.getDeclaredMethod("toString", new Class[] {null});
                            // 如果不抛出 NoSuchMethodException 异常则存在 toString 方法 ,安全的 writeValueAsString ,否则 走 Object的
                            // toString方法
                            params.append(" ").append(paramNames.get(i)).append(": ").append(
                                JSONObject.toJSONString(args[i]));
                        } catch (NoSuchMethodException e) {
                            params.append(" ").append(paramNames.get(i)).append(": ").append(
                                JSONObject.toJSONString(args[i].toString()));
                        }
                    } else if (args[i] instanceof MultipartFile) {
                        MultipartFile file = (MultipartFile)args[i];
                        params.append(" ").append(paramNames.get(i)).append(": ").append(file.getName());
                    } else {
                        params.append(" ").append(paramNames.get(i)).append(": ").append(args[i]);
                    }
                }
            }
            return params;
        }
    }
    
    

    这里使用到了一些工具类,代码我已经上传到仓库了,大家直接down下来就行。

    (3)注解使用

    在IndexController.java中加入该注解即可

    package com.summo.sbmy.web.controller;
    
    import com.summo.sbmy.aspect.visit.VisitLog;
    import org.springframework.stereotype.Controller;
    import org.springframework.web.bind.annotation.GetMapping;
    
    @Controller
    public class IndexController {
    
        @GetMapping("/")
        @VisitLog
        public String index(){
            return "index";
        }
    }
    
    

    2. 前端部分

    (1)新建VisitorLog组件

    代码如下:

    <template>
      <div class="stats-card-container">
        <el-card class="stats-card-main">
          <!-- 突出显示的今日 PV -->
          <div class="stats-section">
            <div class="stats-value-main">{{ statsData.todayPv }}</div>
            <div class="stats-label-main">今日 PV</div>
          </div>
          <!-- 其他统计数据,以更紧凑的形式显示 -->
          <div class="stats-section stats-others">
            <div class="stats-item">
              <div class="stats-value-small">{{ statsData.todayUv }}</div>
              <div class="stats-label-small">今日 UV</div>
            </div>
            <div class="stats-item">
              <div class="stats-value-small">{{ statsData.allPv }}</div>
              <div class="stats-label-small">总 PV</div>
            </div>
            <div class="stats-item">
              <div class="stats-value-small">{{ statsData.allUv }}</div>
              <div class="stats-label-small">总 UV</div>
            </div>
          </div>
        </el-card>
      </div>
    </template>
    <script>
    import apiService from "@/config/apiService.js";
    export default {
      name: "VisitorLog",
      data() {
        return {
          statsData: {
            todayPv: 0,
            todayUv: 0,
            allPv: 0,
            allUv: 0,
          },
        };
      },
      created() {
        this.fetchVisitorCount(); // 组件创建时立即调用一次
        this.startPolling(); // 启动定时器
      },
      beforeDestroy() {
        this.stopPolling(); // 在组件销毁前清理定时器
      },
      methods: {
        fetchVisitorCount() {
          apiService
            .get("/welcome/queryVisitorCount")
            .then((res) => {
              // 处理响应数据
              this.statsData = res.data.data;
            })
            .catch((error) => {
              // 处理错误情况
              console.error(error);
            });
        },
        startPolling() {
          // 定义一个方法来启动周期性的定时器
          this.polling = setInterval(() => {
            this.fetchVisitorCount();
          }, 1000 * 60 * 60); // 每60000毫秒(1分钟)调用一次
        },
        stopPolling() {
          // 定义一个方法来停止定时器
          clearInterval(this.polling);
        },
      },
    };
    </script>
    
    <style scoped>
    >>> .el-card__body{
      padding: 10px;
    }
    .stats-card-container {
      max-width: 400px;
      margin-bottom: 10px;
    }
    
    .stats-card-main {
      padding: 12px;
      box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
    }
    
    .stats-section {
      text-align: center;
    }
    
    .stats-value-main {
      font-size: 24px;
      font-weight: bold;
      color: #0A74DA;
      margin-bottom: 4px;
    }
    
    .stats-label-main {
      font-size: 14px;
      color: #333;
    }
    
    .stats-others {
      display: flex;
      justify-content: space-between;
      margin-top: 12px;
    }
    
    .stats-item {
      text-align: center;
    }
    
    .stats-value-small, .stats-label-small {
      font-size: 12px; /* 减小字体尺寸以实现更紧凑的布局 */
    }
    
    .stats-value-small {
      font-weight: bold;
      color: #333;
      margin-bottom: 2px;
    }
    
    .stats-label-small {
      color: #666;
    }
    
    @media (max-width: 400px) {
      .stats-others {
        flex-direction: column;
        align-items: center;
      }
    
      .stats-item {
        margin-bottom: 8px;
      }
    }
    </style>
    
    

    (2)组件使用

    在App.vue组件中引入VisitorLog组件,顺便将布局重新分一下,代码如下:

    <template>
      <div id="app">
        <el-row :gutter="10">
          <el-col :span="20">
            <el-row :gutter="10">
              <el-col :span="6" v-for="(board, index) in hotBoards" :key="index">
                <hot-search-board
                  :title="board.title"
                  :icon="board.icon"
                  :fetch-url="board.fetchUrl"
                  :type="board.type"
                />
              </el-col>
            </el-row>
          </el-col>
          <el-col :span="4">
            <visitor-log />
          </el-col>
        </el-row>
      </div>
    </template>
    
    <script>
    import HotSearchBoard from "@/components/HotSearchBoard.vue";
    import VisitorLog from "@/components/VisitorLog.vue";
    export default {
      name: "App",
      components: {
        HotSearchBoard,
        VisitorLog,
      },
      data() {
        return {
          hotBoards: [
            {
              title: "百度",
              icon: require("@/assets/icons/baidu-icon.svg"),
              type: "baidu",
            },
            {
              title: "抖音",
              icon: require("@/assets/icons/douyin-icon.svg"),
              type: "douyin",
            },
            {
              title: "知乎",
              icon: require("@/assets/icons/zhihu-icon.svg"),
              type: "zhihu",
            },
            {
              title: "B站",
              icon: require("@/assets/icons/bilibili-icon.svg"),
              type: "bilibili",
            },
            {
              title: "搜狗",
              icon: require("@/assets/icons/sougou-icon.svg"),
              type: "sougou",
            },
          ],
        };
      },
    };
    </script>
    
    <style>
    #app {
      font-family: Avenir, Helvetica, Arial, sans-serif;
      -webkit-font-smoothing: antialiased;
      -moz-osx-font-smoothing: grayscale;
      text-align: center;
      color: #2c3e50;
      margin-top: 60px;
      background: #f8f9fa; /* 提供一个柔和的背景色 */
      min-height: 100vh; /* 使用视口高度确保填满整个屏幕 */
      padding: 0; /* 保持整体布局紧凑,无额外内边距 */
    }
    </style>
    
    

    咱们做出来的效果就是这样的,如下:

    这个小组件做起来还是简单的,主要就是监控了别人的访问IP,然后通过IP反解析出所属地域,最后将其存到数据库中。

    番外:搜狗热搜爬虫

    1. 爬虫方案评估

    搜狗的热搜接口返回的一串JSON格式数据,这就很简单了,省的我们去解析dom,访问链接是:https://go.ie.sogou.com/hot_ranks

    2. 网页解析代码

    SougouHotSearchJob.java

    package com.summo.sbmy.job.sougou;
    
    import com.alibaba.fastjson.JSONArray;
    import com.alibaba.fastjson.JSONObject;
    import com.google.common.collect.Lists;
    import com.summo.sbmy.common.model.dto.HotSearchDetailDTO;
    import com.summo.sbmy.dao.entity.SbmyHotSearchDO;
    import com.summo.sbmy.service.SbmyHotSearchService;
    import com.summo.sbmy.service.convert.HotSearchConvert;
    import com.xxl.job.core.biz.model.ReturnT;
    import com.xxl.job.core.handler.annotation.XxlJob;
    import lombok.extern.slf4j.Slf4j;
    import okhttp3.OkHttpClient;
    import okhttp3.Request;
    import okhttp3.Response;
    import org.apache.commons.collections4.CollectionUtils;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.stereotype.Component;
    
    import java.io.IOException;
    import java.util.Calendar;
    import java.util.List;
    import java.util.Random;
    import java.util.UUID;
    import java.util.stream.Collectors;
    
    import static com.summo.sbmy.common.cache.SbmyHotSearchCache.CACHE_MAP;
    import static com.summo.sbmy.common.enums.HotSearchEnum.DOUYIN;
    import static com.summo.sbmy.common.enums.HotSearchEnum.SOUGOU;
    
    /**
     * @author summo
     * @version SougouHotSearchJob.java, 1.0.0
     * @description 搜狗热搜Java爬虫代码
     * @date 2024年08月09
     */
    @Component
    @Slf4j
    public class SougouHotSearchJob {
    
        @Autowired
        private SbmyHotSearchService sbmyHotSearchService;
    
        @XxlJob("sougouHotSearchJob")
        public ReturnT<String> hotSearch(String param) throws IOException {
            log.info("搜狗热搜爬虫任务开始");
            try {
                //查询搜狗热搜数据
                OkHttpClient client = new OkHttpClient().newBuilder().build();
                Request request = new Request.Builder().url("https://go.ie.sogou.com/hot_ranks").method("GET", null)
                        .build();
                Response response = client.newCall(request).execute();
                JSONObject jsonObject = JSONObject.parseObject(response.body().string());
                JSONArray array = jsonObject.getJSONArray("data");
                List<SbmyHotSearchDO> sbmyHotSearchDOList = Lists.newArrayList();
                for (int i = 0, len = array.size(); i < len; i++) {
                    //获取知乎热搜信息
                    JSONObject object = (JSONObject)array.get(i);
                    //构建热搜信息榜
                    SbmyHotSearchDO sbmyHotSearchDO = SbmyHotSearchDO.builder().hotSearchResource(SOUGOU.getCode()).build();
                    //设置知乎三方ID
                    sbmyHotSearchDO.setHotSearchId(object.getString("id"));
                    //设置文章标题
                    sbmyHotSearchDO.setHotSearchTitle(object.getJSONObject("attributes").getString("title"));
                    //设置文章连接
                    sbmyHotSearchDO.setHotSearchUrl(
                            "https://www.sogou.com/web?ie=utf8&query=" + sbmyHotSearchDO.getHotSearchTitle());
                    //设置热搜热度
                    sbmyHotSearchDO.setHotSearchHeat(object.getJSONObject("attributes").getString("num"));
                    //按顺序排名
                    sbmyHotSearchDO.setHotSearchOrder(i + 1);
                    sbmyHotSearchDOList.add(sbmyHotSearchDO);
                }
                if (CollectionUtils.isEmpty(sbmyHotSearchDOList)) {
                    return ReturnT.SUCCESS;
                }
                //数据加到缓存中
                CACHE_MAP.put(SOUGOU.getCode(), HotSearchDetailDTO.builder()
                    //热搜数据
                    .hotSearchDTOList(
                        sbmyHotSearchDOList.stream().map(HotSearchConvert::toDTOWhenQuery).collect(Collectors.toList()))
                    //更新时间
                    .updateTime(Calendar.getInstance().getTime()).build());
                //数据持久化
                sbmyHotSearchService.saveCache2DB(sbmyHotSearchDOList);
                log.info("搜狗热搜爬虫任务结束");
            } catch (IOException e) {
                log.error("获取搜狗数据异常", e);
            }
            return ReturnT.SUCCESS;
        }
    }