VC Counter —— 轻量站点访问统计组件

基于 PDO + SQLite 的免配置访问计数器:前端展示「今日 / 昨日 / 总访问」,支持悬浮与内联两种模式、数字滚动动画、多站点统计。无需 MySQL,无需管理后台。

代码

1.counter.php

<?php
// counter.php —— SQLite 版(每个站点标识一个独立数据库文件 counter-<site>.sqlite)
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');

// ---------- 跨域白名单:默认为空 = 仅允许本服务器(同源页面)调用 ----------
// 浏览器同源请求本就不需要 CORS 头;若需允许其他域名调用,把来源加入下面数组(须带协议)
$allowedOrigins = [
    // 'https://www.example.com',
    // 'https://blog.example.com',
];
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
if ($origin !== '' && in_array($origin, $allowedOrigins, true)) {
    header('Access-Control-Allow-Origin: ' . $origin);
    header('Vary: Origin');
    header('Access-Control-Allow-Methods: GET, OPTIONS');
    header('Access-Control-Allow-Headers: Content-Type');
}
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(204); exit; }

date_default_timezone_set('Asia/Shanghai');

$debug = isset($_GET['debug']) && $_GET['debug'] == '1';
// 仅 visit 记录访问,其余值一律按只读 read 处理
$mode  = (isset($_GET['mode']) && $_GET['mode'] === 'visit') ? 'visit' : 'read';

// ---------- 站点标识:每个站点一个独立 SQLite 文件,便于分开管理/备份 ----------
$site = isset($_GET['site']) ? trim($_GET['site']) : 'default';
// 只保留字母/数字/下划线/中划线(含多字节字符),避免脏数据入库;非法 UTF-8 时视为空
$site = preg_replace('/[^\w\-]+/u', '', $site) ?? '';
if ($site === '') $site = 'default';
$site = function_exists('mb_substr') ? mb_substr($site, 0, 64) : substr($site, 0, 64);

$dbFile = __DIR__ . '/counter-' . $site . '.sqlite';

// ---------- 环境自检 ----------
if ($debug) {
    $info = [
        'php_version'  => PHP_VERSION,
        'pdo_sqlite'   => extension_loaded('pdo_sqlite') ? 'ok' : 'MISSING',
        'dir'          => __DIR__,
        'dir_writable' => is_writable(__DIR__) ? 'ok' : 'NOT WRITABLE',
        'db_file'      => file_exists($dbFile) ? 'exists' : 'will create',
        'mode'         => $mode,
    ];
    try {
        $pdo = new PDO('sqlite:' . $dbFile);
        $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
        $info['db_connect']     = 'ok';
        $info['sqlite_version'] = $pdo->query('SELECT sqlite_version()')->fetchColumn();
        $info['journal_mode']   = $pdo->query('PRAGMA journal_mode')->fetchColumn();
    } catch (Exception $e) {
        $info['db_connect'] = 'FAIL: ' . $e->getMessage();
    }
    echo json_encode(['code' => 0, 'debug' => $info], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
    exit;
}

$ip = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
    $parts = explode(',', $_SERVER['HTTP_X_FORWARDED_FOR']);
    $candidate = trim($parts[0]);
    // 仅接受合法 IP,防止伪造的 XFF 值污染访客指纹
    if (filter_var($candidate, FILTER_VALIDATE_IP)) {
        $ip = $candidate;
    }
}
$ua = $_SERVER['HTTP_USER_AGENT'] ?? '';
$visitorHash = substr(hash('sha256', $ip . '|' . $ua . '|' . $site), 0, 16);

$today     = date('Y-m-d');
$yesterday = date('Y-m-d', strtotime('-1 day'));

try {
    if (!extension_loaded('pdo_sqlite')) {
        throw new Exception('pdo_sqlite 扩展未启用,请在 PHP 配置中开启');
    }

    $pdo = new PDO('sqlite:' . $dbFile);
    $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);

    // WAL 模式:读写并发不互斥,写不阻塞读
    $pdo->exec('PRAGMA journal_mode=WAL');
    // 平衡性能与安全,WAL 下 NORMAL 已足够
    $pdo->exec('PRAGMA synchronous=NORMAL');
    // 遇到锁时最多等待 5 秒,避免 SQLITE_BUSY 立即失败
    $pdo->exec('PRAGMA busy_timeout=5000');

    // ---------- 建表 ----------
    $pdo->exec("CREATE TABLE IF NOT EXISTS visits (
        id           INTEGER PRIMARY KEY AUTOINCREMENT,
        site         TEXT    NOT NULL,
        date         TEXT    NOT NULL,
        visitor_hash TEXT    NOT NULL,
        created_at   INTEGER NOT NULL,
        UNIQUE(site, date, visitor_hash)
    )");
    $pdo->exec("CREATE INDEX IF NOT EXISTS idx_site_date ON visits (site, date)");

    $pdo->exec("CREATE TABLE IF NOT EXISTS stats (
        site       TEXT    PRIMARY KEY,
        total      INTEGER NOT NULL DEFAULT 0,
        updated_at INTEGER NOT NULL
    )");

    // ---------- visit 模式:记录访问 ----------
    if ($mode === 'visit') {
        $pdo->beginTransaction();
        try {
            // 唯一索引保证同一访客同一天只插一次
            $stmt = $pdo->prepare(
                "INSERT OR IGNORE INTO visits (site, date, visitor_hash, created_at)
                 VALUES (?, ?, ?, ?)"
            );
            $stmt->execute([$site, $today, $visitorHash, time()]);
            $inserted = $stmt->rowCount();

            if ($inserted > 0) {
                // 先尝试 UPDATE,避免每次都 INSERT OR IGNORE
                $stmt = $pdo->prepare(
                    "UPDATE stats SET total = total + 1, updated_at = ? WHERE site = ?"
                );
                $stmt->execute([time(), $site]);

                if ($stmt->rowCount() === 0) {
                    // 站点首次访问,插入初始行
                    $stmt = $pdo->prepare(
                        "INSERT INTO stats (site, total, updated_at) VALUES (?, 1, ?)"
                    );
                    $stmt->execute([$site, time()]);
                }
            }
            $pdo->commit();
        } catch (Exception $e) {
            $pdo->rollBack();
            throw $e;
        }

        // 清理 90 天前明细(约 10% 概率触发,避免每次都 DELETE)
        if (mt_rand(1, 100) <= 10) {
            $cutoff = date('Y-m-d', strtotime('-90 day'));
            $stmt = $pdo->prepare("DELETE FROM visits WHERE site = ? AND date < ?");
            $stmt->execute([$site, $cutoff]);
        }
    }

    // ---------- 查询(visit / read 都执行,read 模式无写操作) ----------
    // 一条 SQL 同时统计今日/昨日,减少一次数据库往返
    $stmt = $pdo->prepare(
        "SELECT COUNT(CASE WHEN date = ? THEN 1 END) AS today_cnt,
                COUNT(CASE WHEN date = ? THEN 1 END) AS yesterday_cnt
         FROM visits WHERE site = ? AND date IN (?, ?)"
    );
    $stmt->execute([$today, $yesterday, $site, $today, $yesterday]);
    $row = $stmt->fetch(PDO::FETCH_ASSOC);
    $todayCount     = (int)$row['today_cnt'];
    $yesterdayCount = (int)$row['yesterday_cnt'];

    $stmt = $pdo->prepare("SELECT total FROM stats WHERE site = ?");
    $stmt->execute([$site]);
    $totalCount = (int)$stmt->fetchColumn();

    echo json_encode([
        'code' => 0,
        'data' => [
            'today'     => $todayCount,
            'yesterday' => $yesterdayCount,
            'total'     => $totalCount,
            'ts'        => time(),
        ]
    ], JSON_UNESCAPED_UNICODE);

} catch (Exception $e) {
    error_log('[VC Counter] ' . $e->getMessage());
    http_response_code(500);
    echo json_encode([
        'code' => 1,
        'msg'  => '服务器内部错误:' . $e->getMessage(),
        'data' => ['today' => 0, 'yesterday' => 0, 'total' => 0]
    ], JSON_UNESCAPED_UNICODE);
}

2.counter.js

/**
 * VC Counter —— 轻量站点访问统计组件(纯前端展示 + SQLite 后端,无需数据库服务)
 *
 * 用法:<script src="counter/counter.js"></script>
 * 配置项(均为可选的 data-* 属性):
 *   data-site          站点标识,默认 "default",用于多站点分开统计
 *   data-api           接口地址,默认脚本同目录下的 counter.php
 *   data-mount         内联挂载选择器(如 #footer),缺省为悬浮模式
 *   data-position      悬浮位置 bottom-right / bottom-left / top-right / top-left
 *   data-theme         主题 dark / light
 *   data-style         自定义样式(CSS 文本或 JSON 对象),悬浮 / 内联模式均生效
 *   data-refresh       自动刷新间隔秒数(>=1 生效),默认不自动刷新,仅加载时请求一次
 *   data-mount-timeout 等待挂载元素的超时毫秒数,默认 5000
 *   data-debug         设为 1 输出控制台调试日志
 * 详细文档见同目录 README.md
 */
(function () {
    'use strict';
    if (window.__vc_counter_loaded) return;
    window.__vc_counter_loaded = true;
  
    // ========== 1. 定位脚本元素(多重兜底) ==========
    var scriptEl = document.currentScript;
    if (!scriptEl) {
      var scripts = document.getElementsByTagName('script');
      for (var i = scripts.length - 1; i >= 0; i--) {
        var src = scripts[i].src || '';
        if (src.indexOf('counter.js') !== -1) { scriptEl = scripts[i]; break; }
      }
      if (!scriptEl && scripts.length) scriptEl = scripts[scripts.length - 1];
    }
    if (!scriptEl) {
      console.error('[VC Counter] 无法定位脚本元素');
      return;
    }
  
    // ========== 2. 读取属性 ==========
    function attr(name, def) {
      var v = scriptEl.getAttribute(name);
      return (v === null || v === '') ? (def || '') : String(v).trim();
    }
    var scriptSrc = scriptEl.src || '';
    var site = attr('data-site', 'default') || 'default';
    var mountSel = attr('data-mount');
    var customStyle = attr('data-style');
    var position = attr('data-position', 'bottom-right') || 'bottom-right';
    var theme = attr('data-theme', 'dark') || 'dark';
    var refreshInterval = parseFloat(attr('data-refresh'));
    // 默认不自动刷新;仅当设置 data-refresh(>=1 的秒数)时才开启定时刷新
    var autoRefresh = !isNaN(refreshInterval) && refreshInterval >= 1;
    var mountTimeout = parseInt(attr('data-mount-timeout'), 10) || 5000;
    var debug = attr('data-debug') === '1';
  
    function resolveApi(raw) {
      if (raw && /^(https?:)?\/\//i.test(raw)) return raw;
      if (scriptSrc) {
        try { return new URL(raw || 'counter.php', scriptSrc).href; } catch (e) {}
      }
      return raw || 'counter.php';
    }
    var api = resolveApi(attr('data-api'));
  
    function log() {
      if (!debug) return;
      var a = Array.prototype.slice.call(arguments);
      a.unshift('[VC Counter]');
      console.log.apply(console, a);
    }
    function warn() {
      var a = Array.prototype.slice.call(arguments);
      a.unshift('[VC Counter]');
      console.warn.apply(console, a);
    }
  
    log('配置:', {
      api: api, site: site, mount: mountSel || '(无)', style: customStyle || '(无)',
      position: position, theme: theme, refresh: autoRefresh ? refreshInterval + 's' : '关闭'
    });
  
    // ========== 3. 注入样式 ==========
    if (!document.getElementById('vc-counter-style')) {
      var styleEl = document.createElement('style');
      styleEl.id = 'vc-counter-style';
      styleEl.textContent = `
        .vc-bar{--vc-dh:18px;display:inline-flex;align-items:center;gap:12px;
          padding:6px 12px;border-radius:20px;
          font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
          font-size:13px;line-height:1.4;box-shadow:0 4px 16px rgba(0,0,0,.15);
          backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);
          user-select:none;opacity:.95;transition:opacity .3s}
        .vc-bar:hover{opacity:1}
        .vc-bar.vc-floating{position:fixed;z-index:99999;pointer-events:none}
        .vc-bar.vc-floating:hover{pointer-events:auto}
        .vc-bottom-right{bottom:16px;right:16px}
        .vc-bottom-left{bottom:16px;left:16px}
        .vc-top-right{top:16px;right:16px}
        .vc-top-left{top:16px;left:16px}
        .vc-bar.vc-inline{position:relative;z-index:1;pointer-events:auto}
        .vc-theme-dark{background:rgba(30,34,45,.85);color:#e8ecf4;border:1px solid rgba(255,255,255,.08)}
        .vc-theme-light{background:rgba(255,255,255,.9);color:#1a1d29;border:1px solid rgba(0,0,0,.06)}
        .vc-item{display:flex;align-items:center;gap:4px;white-space:nowrap}
        .vc-label{font-size:12px;opacity:.7}
        .vc-number{display:inline-flex;overflow:hidden;height:var(--vc-dh);line-height:var(--vc-dh);
          vertical-align:bottom;font-weight:600;font-variant-numeric:tabular-nums;letter-spacing:.5px}
        .vc-digit{display:inline-block;width:.65em;height:var(--vc-dh);overflow:hidden;position:relative}
        .vc-strip{display:flex;flex-direction:column;
          transition:transform .7s cubic-bezier(.23,1,.32,1);will-change:transform}
        .vc-strip span{display:block;height:var(--vc-dh);line-height:var(--vc-dh);text-align:center}
        .vc-dot{width:6px;height:6px;border-radius:50%;background:#4ade80;
          box-shadow:0 0 6px #4ade80;animation:vc-pulse 1.6s ease-in-out infinite}
        @keyframes vc-pulse{0%,100%{opacity:1}50%{opacity:.3}}
        @media (max-width:480px){.vc-bar{--vc-dh:15px;gap:8px;padding:4px 8px;font-size:11px}
          .vc-label{font-size:10px}.vc-digit{width:.55em}}
      `;
      document.head.appendChild(styleEl);
    }
  
    // ========== 4. 构建计数器 ==========
    function buildBar() {
      var bar = document.createElement('div');
      bar.className = 'vc-bar vc-theme-' + theme;
      bar.innerHTML =
        '<span class="vc-dot"></span>' +
        '<div class="vc-item"><span class="vc-label">今日访问</span><span class="vc-number" data-key="today"></span></div>' +
        '<div class="vc-item"><span class="vc-label">昨日访问</span><span class="vc-number" data-key="yesterday"></span></div>' +
        '<div class="vc-item"><span class="vc-label">总访问</span><span class="vc-number" data-key="total"></span></div>';
      return bar;
    }
  
    function applyPosition(bar, isInline) {
      if (isInline) { bar.classList.add('vc-inline'); }
      else {
        bar.classList.add('vc-floating');
        if (!customStyle) bar.classList.add('vc-' + position);
      }
      // 自定义样式:悬浮 / 内联模式均生效,支持 CSS 文本或 JSON 对象
      if (customStyle) {
        var css = customStyle.trim();
        if (css.charAt(0) === '{') {
          try {
            var obj = JSON.parse(css);
            Object.keys(obj).forEach(function (k) { bar.style[k] = obj[k]; });
          } catch (e) { bar.style.cssText = css; }
        } else {
          bar.style.cssText = css;
        }
      }
    }
  
    // ========== 5. 等待挂载元素 ==========
    function waitForElement(selector, timeout, cb) {
      function tryFind() {
        var el = document.querySelector(selector);
        if (el) return el;
        // 容错:如果用户写的是 id 值(没带 #),再试一次
        if (selector.charAt(0) !== '#' && selector.charAt(0) !== '.' &&
            selector.charAt(0) !== '[' && selector.indexOf(' ') === -1) {
          try { el = document.getElementById(selector); } catch (e) {}
        }
        return el;
      }
  
      var el = tryFind();
      if (el) { cb(el); return; }
  
      var done = false;
      var start = Date.now();
      var observer = null;
      var timer = null;
  
      function finish(found) {
        if (done) return;
        done = true;
        if (observer) observer.disconnect();
        if (timer) clearInterval(timer);
        cb(found);
      }
  
      observer = new MutationObserver(function () {
        var found = tryFind();
        if (found) finish(found);
      });
      observer.observe(document.documentElement || document, { childList: true, subtree: true });
  
      timer = setInterval(function () {
        var found = tryFind();
        if (found) return finish(found);
        if (Date.now() - start > timeout) finish(null);
      }, 200);
    }
  
    // ========== 6. 数字滚动 + AJAX ==========
    function startCounter(bar) {
      var els = {
        today: bar.querySelector('[data-key="today"]'),
        yesterday: bar.querySelector('[data-key="yesterday"]'),
        total: bar.querySelector('[data-key="total"]')
      };
  
      function makeStrip(d) {
        var s = document.createElement('span');
        s.className = 'vc-strip';
        for (var i = 0; i < 10; i++) {
          var n = document.createElement('span');
          n.textContent = i;
          s.appendChild(n);
        }
        s.style.transform = 'translateY(-' + (d * 10) + '%)';
        return s;
      }
  
      function updateNumber(container, value) {
        if (container.dataset.value === String(value)) return;
        var str = String(value);
        var digits = container.querySelectorAll('.vc-digit');
        if (digits.length === str.length) {
          for (var i = 0; i < str.length; i++) {
            var d = parseInt(str[i], 10);
            var strip = digits[i].querySelector('.vc-strip');
            if (strip) strip.style.transform = 'translateY(-' + (d * 10) + '%)';
          }
        } else {
          // 位数变化(含首次加载)时:先按 0 位渲染并强制回流,再滚到目标数字;
          // 否则新建 strip 直接带终值 transform,浏览器不渲染起始态,滚动动画不会触发
          container.innerHTML = '';
          for (var j = 0; j < str.length; j++) {
            var dEl = document.createElement('span');
            dEl.className = 'vc-digit';
            dEl.appendChild(makeStrip(0));
            container.appendChild(dEl);
          }
          void container.offsetWidth;
          var strips = container.querySelectorAll('.vc-strip');
          for (j = 0; j < str.length; j++) {
            strips[j].style.transform =
              'translateY(-' + (parseInt(str[j], 10) * 10) + '%)';
          }
        }
        container.dataset.value = String(value);
      }
  
      Object.keys(els).forEach(function (k) { updateNumber(els[k], 0); });
  
      var isFirst = true;
      var inflight = false;
      var failCount = 0;
      var timer = null;
  
      function fetchData(onDone) {
        if (inflight) { if (onDone) onDone(); return; }
        inflight = true;
        var mode = isFirst ? 'visit' : 'read';
        var url = api + (api.indexOf('?') === -1 ? '?' : '&') +
          'site=' + encodeURIComponent(site) +
          '&mode=' + mode + '&t=' + Date.now();
        log('请求(' + mode + '):', url);
  
        var xhr = new XMLHttpRequest();
        xhr.open('GET', url, true);
        xhr.timeout = 8000;
  
        // 统一收尾:无论成败只触发一次(超时/出错后 readyState=4 不会重复结算)
        function settle(ok) {
          if (!inflight) return;
          inflight = false;
          failCount = ok ? 0 : failCount + 1;
          if (onDone) onDone();
        }
  
        xhr.onreadystatechange = function () {
          if (xhr.readyState !== 4) return;
          if (xhr.status >= 200 && xhr.status < 300) {
            try {
              var json = JSON.parse(xhr.responseText);
              if (json && json.code === 0 && json.data) {
                updateNumber(els.today, json.data.today || 0);
                updateNumber(els.yesterday, json.data.yesterday || 0);
                updateNumber(els.total, json.data.total || 0);
                isFirst = false;
                log('更新:', json.data);
                settle(true);
              } else {
                warn('数据异常', json);
                settle(false);
              }
            } catch (e) {
              warn('JSON 解析失败', xhr.responseText);
              settle(false);
            }
          } else {
            warn('HTTP ' + xhr.status + ' ' + url);
            settle(false);
          }
        };
        xhr.ontimeout = function () { warn('请求超时'); settle(false); };
        xhr.onerror = function () { warn('网络错误'); settle(false); };
        xhr.send();
      }
  
      function nextDelay() {
        // 正常按 data-refresh 轮询;连续失败时指数退避,最长放大 8 倍
        return refreshInterval * 1000 * Math.min(Math.pow(2, failCount), 8);
      }
  
      // 定时刷新采用 setTimeout 链式调度:上一次请求完成后才安排下一次,避免请求堆积
      function schedule() {
        if (!autoRefresh || document.hidden) return;
        stop();
        timer = setTimeout(function () { fetchData(schedule); }, nextDelay());
      }
  
      function start() {
        stop();
        fetchData(schedule);
      }
      function stop() {
        if (timer) { clearTimeout(timer); timer = null; }
      }
  
      // 仅自动刷新模式下监听页面可见性:切走暂停,切回立即刷新
      if (autoRefresh) {
        document.addEventListener('visibilitychange', function () {
          if (document.hidden) stop();
          else start();
        });
      }
  
      start();
    }
  
    // ========== 7. 初始化 ==========
    function init() {
      if (!document.body) { setTimeout(init, 50); return; }
  
      if (mountSel) {
        log('查找挂载元素:' + mountSel);
        waitForElement(mountSel, mountTimeout, function (el) {
          var bar = buildBar();
          if (el) {
            log('✓ 找到挂载元素,使用内联模式');
            applyPosition(bar, true);
            el.appendChild(bar);
          } else {
            warn('✗ 等待 ' + mountTimeout + 'ms 未找到 ' + mountSel + ',退化为悬浮模式');
            applyPosition(bar, false);
            document.body.appendChild(bar);
          }
          startCounter(bar);
        });
      } else {
        var bar = buildBar();
        log('未配置 data-mount,使用悬浮模式');
        applyPosition(bar, false);
        document.body.appendChild(bar);
        startCounter(bar);
      }
    }
  
    if (document.readyState === 'loading') {
      document.addEventListener('DOMContentLoaded', init);
    } else {
      init();
    }
  })();

3.counter-json.php(可选)

<?php
// counter-json.php —— JSON 文件存储版(无需 pdo_sqlite 扩展,每个站点标识一个独立数据文件 counter-<site>.json)
// 接口与 counter.php 完全一致,前端通过 data-api 指向本文件即可切换
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');

// ---------- 跨域白名单:默认为空 = 仅允许本服务器(同源页面)调用 ----------
// 浏览器同源请求本就不需要 CORS 头;若需允许其他域名调用,把来源加入下面数组(须带协议)
$allowedOrigins = [
    // 'https://www.example.com',
    // 'https://blog.example.com',
];
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
if ($origin !== '' && in_array($origin, $allowedOrigins, true)) {
    header('Access-Control-Allow-Origin: ' . $origin);
    header('Vary: Origin');
    header('Access-Control-Allow-Methods: GET, OPTIONS');
    header('Access-Control-Allow-Headers: Content-Type');
}
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(204); exit; }

date_default_timezone_set('Asia/Shanghai');

$debug = isset($_GET['debug']) && $_GET['debug'] == '1';
// 仅 visit 记录访问,其余值一律按只读 read 处理
$mode  = (isset($_GET['mode']) && $_GET['mode'] === 'visit') ? 'visit' : 'read';

// ---------- 站点标识:每个站点一个独立 JSON 文件,便于分开管理/备份 ----------
$site = isset($_GET['site']) ? trim($_GET['site']) : 'default';
// 只保留字母/数字/下划线/中划线(含多字节字符),避免脏数据入库;非法 UTF-8 时视为空
$site = preg_replace('/[^\w\-]+/u', '', $site) ?? '';
if ($site === '') $site = 'default';
$site = function_exists('mb_substr') ? mb_substr($site, 0, 64) : substr($site, 0, 64);

$dataFile = __DIR__ . '/counter-' . $site . '.json';

// ---------- 环境自检 ----------
if ($debug) {
    $info = [
        'php_version'  => PHP_VERSION,
        'storage'      => 'json',
        'dir'          => __DIR__,
        'dir_writable' => is_writable(__DIR__) ? 'ok' : 'NOT WRITABLE',
        'data_file'    => file_exists($dataFile) ? 'exists' : 'will create',
        'mode'         => $mode,
    ];
    try {
        $data = readStore($dataFile);
        $info['storage_check'] = 'ok';
        $info['total'] = (int)($data['total'] ?? 0);
    } catch (Exception $e) {
        $info['storage_check'] = 'FAIL: ' . $e->getMessage();
    }
    echo json_encode(['code' => 0, 'debug' => $info], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
    exit;
}

$ip = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
    $parts = explode(',', $_SERVER['HTTP_X_FORWARDED_FOR']);
    $candidate = trim($parts[0]);
    // 仅接受合法 IP,防止伪造的 XFF 值污染访客指纹
    if (filter_var($candidate, FILTER_VALIDATE_IP)) {
        $ip = $candidate;
    }
}
$ua = $_SERVER['HTTP_USER_AGENT'] ?? '';
$visitorHash = substr(hash('sha256', $ip . '|' . $ua . '|' . $site), 0, 16);

$today     = date('Y-m-d');
$yesterday = date('Y-m-d', strtotime('-1 day'));

try {
    if ($mode === 'visit') {
        // ---------- visit 模式:独占锁下读改写,记录访问 ----------
        $fp = fopen($dataFile, 'c+');
        if (!$fp) throw new Exception('无法打开数据文件(目录不可写?)');
        if (!flock($fp, LOCK_EX)) { fclose($fp); throw new Exception('文件锁获取失败'); }
        $data = decodeStore(stream_get_contents($fp));

        if (!isset($data['days'][$today]) || !in_array($visitorHash, $data['days'][$today], true)) {
            // 同一访客同一天只记一次;总访问数仅在新增访客时 +1
            $data['days'][$today][] = $visitorHash;
            $data['total'] = (int)($data['total'] ?? 0) + 1;
            $data['updated_at'] = time();

            // 清理 90 天前明细(约 10% 概率触发,避免每次都做多余清理)
            if (mt_rand(1, 100) <= 10) {
                $cutoff = date('Y-m-d', strtotime('-90 day'));
                foreach (array_keys($data['days']) as $d) {
                    if ($d < $cutoff) unset($data['days'][$d]);
                }
            }

            ftruncate($fp, 0);
            rewind($fp);
            fwrite($fp, json_encode($data, JSON_UNESCAPED_UNICODE));
            fflush($fp);
        }
        flock($fp, LOCK_UN);
        fclose($fp);
    } else {
        // ---------- read 模式:共享锁只读,无写操作 ----------
        $data = readStore($dataFile);
    }

    // ---------- 查询(visit / read 都执行) ----------
    $todayCount     = isset($data['days'][$today]) ? count($data['days'][$today]) : 0;
    $yesterdayCount = isset($data['days'][$yesterday]) ? count($data['days'][$yesterday]) : 0;
    $totalCount     = (int)($data['total'] ?? 0);

    echo json_encode([
        'code' => 0,
        'data' => [
            'today'     => $todayCount,
            'yesterday' => $yesterdayCount,
            'total'     => $totalCount,
            'ts'        => time(),
        ]
    ], JSON_UNESCAPED_UNICODE);

} catch (Exception $e) {
    error_log('[VC Counter] ' . $e->getMessage());
    http_response_code(500);
    echo json_encode([
        'code' => 1,
        'msg'  => '服务器内部错误:' . $e->getMessage(),
        'data' => ['today' => 0, 'yesterday' => 0, 'total' => 0]
    ], JSON_UNESCAPED_UNICODE);
}

// ========== JSON 存储工具函数 ==========
// 文件结构:{"total":0,"updated_at":0,"days":{"YYYY-MM-DD":["访客hash",...]}}

// 共享锁只读;文件不存在时返回空数据
function readStore($file) {
    if (!file_exists($file)) return ['total' => 0, 'updated_at' => 0, 'days' => []];
    $fp = fopen($file, 'r');
    if (!$fp) throw new Exception('无法读取数据文件');
    if (!flock($fp, LOCK_SH)) { fclose($fp); throw new Exception('文件锁获取失败'); }
    $raw = stream_get_contents($fp);
    flock($fp, LOCK_UN);
    fclose($fp);
    return decodeStore($raw);
}

// 解码并补齐默认结构;文件损坏时从空数据开始(不中断计数)
function decodeStore($raw) {
    $data = json_decode((string)$raw, true);
    if (!is_array($data)) $data = [];
    $data['total'] = isset($data['total']) ? (int)$data['total'] : 0;
    $data['updated_at'] = isset($data['updated_at']) ? (int)$data['updated_at'] : 0;
    if (!isset($data['days']) || !is_array($data['days'])) $data['days'] = [];
    // 过滤掉非数组的天明细,防止后续 count() 报错
    $data['days'] = array_filter($data['days'], 'is_array');
    return $data;
}

文件说明

文件说明
counter.js前端脚本:展示计数条、请求接口、数字滚动动画
counter.php后端接口:记录/读取访问数据(SQLite 存储)
counter-json.php后端接口:记录/读取访问数据(JSON 文件存储,无需 pdo_sqlite 扩展,接口与 counter.php 完全一致)
counter-<site>.sqlite数据库文件,每个站点标识一个独立库(如 counter-default.sqlite),首次访问自动创建,需目录可写
counter-<site>.jsonJSON 版数据文件(仅使用 counter-json.php 时生成),每个站点标识一个独立文件

环境要求

  • PHP 7.0+

    • SQLite 版(counter.php):需启用 pdo_sqlite 扩展
    • JSON 版(counter-json.php):无扩展要求,任何 PHP 环境均可运行
  • counter 目录对 PHP 进程可写(用于创建数据文件)

快速开始

  1. 将整个 counter 目录上传到网站(如 /counter/)。
  2. 在需要展示的页面引入脚本(建议放在 </body> 前):
<script src="/counter/counter.js"></script>
  1. 打开页面即可看到右下角悬浮的统计条,首次访问自动建库并记录。
  2. 环境自检:浏览器访问 http://你的域名/counter/counter.php?debug=1,返回 JSON 中各项为 ok 即正常。

前端配置项(data-* 属性,均可选)

属性默认值说明
data-sitedefault站点标识,同一份代码可给多个站点分别计数
data-api同目录 counter.php接口地址,跨域部署时填完整 URL
data-mount空(悬浮模式)内联挂载元素选择器,如 #footer-stats;等待超时后自动退化为悬浮模式
data-positionbottom-right悬浮位置:bottom-right / bottom-left / top-right / top-left
data-themedark主题:dark / light
data-style空自定义样式,支持 CSS 文本或 JSON 对象;悬浮 / 内联模式均生效
data-refresh关闭自动刷新间隔(秒,≥1 生效);默认不自动刷新,仅页面加载时请求一次
data-mount-timeout5000等待挂载元素出现的超时(毫秒)
data-debug0设为 1 时在控制台输出调试日志

示例

<!-- 最简用法:默认不自动刷新,页面加载时统计并展示一次 -->
<script src="/counter/counter.js"></script>

<!-- 独立站点标识 + 内联挂载 + 10 秒自动刷新 -->
<script src="/counter/counter.js"
        data-site="blog"
        data-mount="#stats"
        data-theme="light"
        data-refresh="10"
        data-debug="1"></script>

刷新行为说明

  • 默认(未设置 data-refresh):页面加载时请求一次(visit 模式记录本次访问并返回数据),之后不再发起任何请求。
  • 设置 data-refresh="N"(N ≥ 1):开启定时刷新,每 N 秒读取一次最新数据(read 模式,不重复计数):

    • 采用链式调度,上一次请求完成后才发起下一次,不会出现请求堆积;
    • 连续失败时自动指数退避(间隔最长放大到 8 倍),恢复成功后自动回到正常间隔;
    • 切换到后台标签页时暂停刷新,切回时立即刷新一次。

后端接口

记录并读取(visit 模式)

GET /counter/counter.php?site=blog&mode=visit

只读数据(read 模式,不计入统计)

GET /counter/counter.php?site=blog&mode=read

返回:

{
  "code": 0,
  "data": { "today": 12, "yesterday": 45, "total": 1024, "ts": 1760000000 }
}

环境自检(debug)

GET /counter/counter.php?debug=1

数据存储

两个后端版本数据结构等价、接口一致,任选其一即可,不可混用(数据互不相通)。

SQLite 版(默认,counter.php)

  • 按站点分库:每个站点标识对应一个独立数据库文件 counter-<站点标识>.sqlite(如 counter-blog.sqlite),互不干扰,便于分开管理、备份与迁移。
  • 首次使用某站点标识时自动建库,各站点数据互不关联。
  • 库内 visits 表:按「日期 + 访客指纹(IP + UA 哈希)」唯一去重,同一访客每天只计一次;明细保留 90 天,过期自动清理。
  • 库内 stats 表:该站点累计总访问数。
  • 备份只需复制对应站点的 counter-<site>.sqlite 文件。

JSON 版(counter-json.php)

  • 主机不支持 pdo_sqlite 时使用,前端加 data-api 指向本文件即可:
<script src="/counter/counter.js" data-api="/counter/counter-json.php"></script>
  • 每个站点标识一个独立文件 counter-<站点标识>.json(如 counter-blog.json),首次访问自动创建。
  • 文件结构:{"total": 累计总数, "updated_at": 更新时间, "days": {"日期": ["访客指纹", ...]}},同一访客每天只计一次;明细同样保留 90 天。
  • 写入采用独占文件锁(flock),并发访问不会互相覆盖;高并发大流量站点建议优先用 SQLite 版。
  • 备份只需复制对应站点的 counter-<site>.json 文件。

常见问题

Q:只想允许本站调用,禁止其他网站调用?
默认即是:counter.php 顶部的 $allowedOrigins 白名单为空时,仅同源页面可读取数据,其他域名的浏览器请求会被拦截。若需放开某个域名,把来源(须带协议)加入数组即可,如 'https://www.example.com'。注意:该限制对浏览器生效,无法拦截服务器端的直接请求。

Q:页面显示 0 或接口返回 500?
访问 counter.php?debug=1 自检:确认 pdo_sqlite 为 ok、dir_writable 为 ok;若扩展缺失需在 php.ini 中开启 extension=pdo_sqlite,或改用 JSON 版 counter-json.php(无扩展要求)。

Q:想开启自动刷新?
加上 data-refresh="N"(N 为秒数)即可,例如 data-refresh="30" 表示每 30 秒刷新一次。

Q:统计数比实际访问量小?
同一访客(IP + UA)同一天只计一次,属于正常去重。

Q:多个页面 / 多个域名如何共用或分开统计?
各页面 data-site 填相同标识即合并统计;填不同标识则分开统计。

Q:如何重置某个站点的数据?
停止该站点访问后删除对应的 counter-<site>.sqlite(如有 -wal / -shm 伴生文件一并删除),下次访问自动重建。

评论 (0)