moecounter的原作者的网站访问速度太慢,多数情况下计数器无法显示,我追求的是比较稳定的方案,因此选择了自建。由于我对于各种知识的理解还不充足,而且文笔也一般,大家将就着看吧。网上已有大量的自建教程,现在ai很发达,有不懂的直接问ai,展示的效果如下图所示


创建node环境,配置如图

启动命令

pnpm start

Nodejs版本:25.8.0

端口自己填,填完之后去安全组打开。

填完这些内容就会生成一个项目文件夹里面是空的,点击项目目录,将项目的zip压缩包解压并上传至项目目录。项目地址:https://github.com/journey-ad/Moe-Counter

查看一下自己的日志(容器页面),看有没有成功启动(成功启动会显示Your app is listening on port 3001)。但是这个时候还打不开,需要自己配置一下反向代理,就用1panel的网站功能即可轻松配置,这里不再过多赘述(或许我以后闲的时候会回来补充)

点开自己的代理地址,就能成功访问页面了。(会有图片无法显示)

还没完,需要在网站页面修改自己的nginx配置(openresty也一样),server_name需要改成自己的代理地址。

server {
    listen 80 ; 
    listen 443 ssl ; 
    server_name <your address>; 
    index index.php index.html index.htm default.php default.htm default.html; 
    access_log /www/sites/moecounter.sakurafishermua.top/log/access.log main; 
    error_log /www/sites/moecounter.sakurafishermua.top/log/error.log; 

    location ~ ^/(\.user.ini|\.htaccess|\.git|\.env|\.svn|\.project|LICENSE|README.md) {
        return 404; 
    }
    location / {
        proxy_pass http://127.0.0.1:3001;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
    # root /www/sites/moecounter.sakurafishermua.top/index;  # 建议注释

    http2 on; 
    if ($scheme = http) {
        return 301 https://$host$request_uri; 
    }
    ssl_certificate /www/sites/moecounter.sakurafishermua.top/ssl/fullchain.pem; 
    ssl_certificate_key /www/sites/moecounter.sakurafishermua.top/ssl/privkey.pem; 
    ssl_protocols TLSv1.3 TLSv1.2; 
    ssl_ciphers ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-SHA384:ECDHE-RSA-AES128-SHA256:!aNULL:!eNULL:!EXPORT:!DSS:!DES:!RC4:!3DES:!MD5:!PSK:!KRB5:!SRP:!CAMELLIA:!SEED; 
    ssl_prefer_server_ciphers off; 
    ssl_session_cache shared:SSL:10m; 
    ssl_session_timeout 10m; 
    error_page 497 https://$host$request_uri; 
    proxy_set_header X-Forwarded-Proto https; 
    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains"; 

    # include /www/sites/moecounter.sakurafishermua.top/proxy/*.conf; # 避免重复location
}

修改项目目录里的index.js文件(直接复制就行)

"use strict";
const path = require('path');
require('dotenv').config();
const express = require("express");
const compression = require("compression");
const { z } = require("zod");

const db = require("./db");
const { themeList, getCountImage } = require("./utils/themify");
const { cors, ZodValid } = require("./utils/middleware");
const { randomArray, logger } = require("./utils");

const app = express();

//app.use(express.static("assets"));
app.use(express.static(path.join(__dirname, "assets")));
app.use(compression());
app.use(cors());
app.set("view engine", "pug");

app.get('/', (req, res) => {
  const site = process.env.APP_SITE || `${req.protocol}://${req.get('host')}`
  const ga_id = process.env.GA_ID || null
  res.render('index', {
    site,
    ga_id,
    themeList,
  })
});

// get the image
app.get(["/@:name", "/get/@:name"],
  ZodValid({
    params: z.object({
      name: z.string().max(32),
    }),
    query: z.object({
      theme: z.string().default("moebooru"),
      padding: z.coerce.number().int().min(0).max(16).default(7),
      offset: z.coerce.number().min(-500).max(500).default(0),
      align: z.enum(["top", "center", "bottom"]).default("top"),
      scale: z.coerce.number().min(0.1).max(2).default(1),
      pixelated: z.enum(["0", "1"]).default("1"),
      darkmode: z.enum(["0", "1", "auto"]).default("auto"),

      // Unusual Options
      num: z.coerce.number().int().min(0).max(1e15).default(0), // a carry-safe integer, less than `2^53-1`, and aesthetically pleasing in decimal.
      prefix: z.coerce.number().int().min(-1).max(999999).default(-1)
    })
  }),
  async (req, res) => {
    const { name } = req.params;
    let { theme = "moebooru", num = 0, ...rest } = req.query;

    // This helps with GitHub's image cache
    res.set({
      "content-type": "image/svg+xml",
      "cache-control": "max-age=0, no-cache, no-store, must-revalidate",
    });

    const data = await getCountByName(String(name), Number(num));

    if (name === "demo") {
      res.set("cache-control", "max-age=31536000");
    }

    if (theme === "random") {
      theme = randomArray(Object.keys(themeList));
    }

    // Send the generated SVG as the result
    const renderSvg = getCountImage({
      count: data.num,
      theme,
      ...rest
    });

    res.send(renderSvg);

    logger.debug(
      data,
      { theme, ...req.query },
      `ip: ${req.headers['x-forwarded-for'] || req.connection.remoteAddress}`,
      `ref: ${req.get("Referrer") || null}`,
      `ua: ${req.get("User-Agent") || null}`
    );
  }
);

// JSON record
app.get("/record/@:name", async (req, res) => {
  const { name } = req.params;

  const data = await getCountByName(name);

  res.json(data);
});

app.get("/heart-beat", (req, res) => {
  res.set("cache-control", "max-age=0, no-cache, no-store, must-revalidate");
  res.send("alive");
  logger.debug("heart-beat");
});

const listener = app.listen(process.env.APP_PORT || 3000, () => {
  logger.info("Your app is listening on port " + listener.address().port);
});

let __cache_counter = {};
let enablePushDelay = process.env.DB_INTERVAL > 0
let needPush = false;

if (enablePushDelay) {
  setInterval(() => {
    needPush = true;
  }, 1000 * process.env.DB_INTERVAL);
}

async function pushDB() {
  if (Object.keys(__cache_counter).length === 0) return;
  if (enablePushDelay && !needPush) return;

  try {
    needPush = false;
    logger.info("pushDB", __cache_counter);

    const counters = Object.keys(__cache_counter).map((key) => {
      return {
        name: key,
        num: __cache_counter[key],
      };
    });

    await db.setNumMulti(counters);
    __cache_counter = {};
  } catch (error) {
    logger.error("pushDB is error: ", error);
  }
}

async function getCountByName(name, num) {
  const defaultCount = { name, num: 0 };

  if (name === "demo") return { name, num: "0123456789" };

  if (num > 0) { return { name, num } };

  try {
    if (!(name in __cache_counter)) {
      const counter = (await db.getNum(name)) || defaultCount;
      __cache_counter[name] = counter.num + 1;
    } else {
      __cache_counter[name]++;
    }

    pushDB();

    return { name, num: __cache_counter[name] };
  } catch (error) {
    logger.error("get count by name is error: ", error);
    return defaultCount;
  }
}

修改/views/index.pug文件。

html
  head
    title='Moe Counter!'
    meta(name='viewport', content='width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no')
    link(rel='icon', type='image/png', href='/favicon.png')
    link(rel='stylesheet', href='https://cdn.jsdelivr.net/npm/normalize.css')
    link(rel='stylesheet', href='https://cdn.jsdelivr.net/npm/bamboo.css')
    link(rel='stylesheet/less', href='/style.less')
    script(less, src='https://cdn.jsdelivr.net/npm/less')
    if ga_id
      <!-- Global site tag (gtag.js) - Google Analytics -->
      script(async, src=`https://www.googletagmanager.com/gtag/js?id=${ga_id}`)
    script.
      window.dataLayer = window.dataLayer || [];
      function gtag() { dataLayer.push(arguments); }
      gtag('js', new Date());

      gtag('config', '#{ga_id}');

      function _evt_push(type, category, label) {
        gtag('event', type, {
          'event_category' : category,
          'event_label' : label
        });
      }

    script.
      var __global_data = { site: "#{site}" };

  body
    h1#main_title
      i Moe Counter!

    h3 How to use
    p Set a unique id for your counter, replace 
      code :name
      |  in the url, That's it!

    h5 SVG address
    code /@:name

    h5 Img tag
    code &lt;img src="/@:name" alt=":name" />

    h5 Markdown
    code ![:name](/@:name)

    h5 e.g.
      img(src='/@index' alt="Moe Counter!")

    details#themes
      summary#more_theme(onclick='_evt_push("click", "normal", "more_theme")')
        h3 More theme✨
      p Just use the query parameters <code>theme</code>, like this: <code>/@:name?theme=moebooru</code>
      each theme in Object.keys(themeList)
        div.item(data-theme=theme)
          h5 #{theme}
          img(data-src=`/@demo?theme=${theme}` alt=theme)

    h3 Credits
    ul
      li: a(href='https://space.bilibili.com/703007996', target='_blank', title='A-SOUL_Official') A-SOUL
      li: a(href='https://github.com/moebooru/moebooru', target='_blank', rel='nofollow') moebooru
      li
        a(href='javascript:alert("!!! NSFW LINK !!!\\nPlease enter the url manually")') gelbooru.com
        |  NSFW
      li: a(href='https://icons8.com/icon/80355/star', target='_blank', rel='nofollow') Icons8
      span: i And all booru site...

    h3 Tool
    .tool
      table
        thead
          tr
            th Param
            th Description
            th Value
        tbody
          tr
            td: code name
            td Unique counter name
            td: input#name(type='text', placeholder=':name')
          tr
            td: code theme
            td Select a counter image theme, default is 
              code moebooru
            td
              select#theme
                option(value="random", selected) * random
                each theme in Object.keys(themeList)
                  option(value=theme) #{theme}
          tr
            td: code padding
            td Set the minimum length, between 1-16, default is 
              code 7
            td: input#padding(type='number', value='7', min='1', max='32', step='1', oninput='this.value = this.value.replace(/[^0-9]/g, "")')
          tr
            td: code offset
            td Set the offset pixel value, between -500-500, default is 
              code 0
            td: input#offset(type='number', value='0', min='-500', max='500', step='1', oninput='this.value = this.value.replace(/[^0-9|\-]/g, "")')
          tr
            td: code scale
            td Set the image scale, between 0.1-2, default is 
              code 1
            td: input#scale(type='number', value='1', min='0.1', max='2', step='0.1', oninput='this.value = this.value.replace(/[^0-9|\.]/g, "")')
          tr
            td: code align
            td Set the image align, Enum top/center/bottom, default is 
              code top
            td: select#align(name="align")
                option(value="top", selected) top
                option(value="center") center
                option(value="bottom") bottom
          tr
            td: code pixelated
            td Enable pixelated mode, Enum 0/1, default is 
              code 1
            td
              input#pixelated(type='checkbox', role='switch', checked)
              label(for='pixelated'): span
          tr
            td: code darkmode
            td Enable dark mode, Enum 0/1/auto, default is 
              code auto
            td: select#darkmode(name="darkmode")
                option(value="auto", selected) auto
                option(value="1") yes
                option(value="0") no
          tr
            td(colspan=3)
              h4.caption Unusual Options
          tr
            td: code num
            td Set counter display number, 0 for disable, default is 
              code 0
            td: input#num(type='number', value='0', min='0', max='1e15', step='1', oninput='this.value = this.value.replace(/[^0-9]/g, "")')
          tr
            td: code prefix
            td Set the prefix number, empty for disable
            td: input#prefix(type='number', value='', min='0', max='999999', step='1', oninput='this.value = this.value.replace(/[^0-9]/g, "")')

      button#get(onclick='_evt_push("click", "normal", "get_counter")') Generate

      div
        code#code
        img#result

    p.github
      a(href='https://github.com/journey-ad/Moe-Counter', target='_blank', onclick='_evt_push("click", "normal", "go_github")') source code

    div.back-to-top

    script(async, src='https://cdn.jsdelivr.net/npm/party-js@2/bundle/party.min.js')
    script(async, src='/script.js')

部署完之后重启项目,页面就能正常显示了,快去试试吧!