笑话网站源码怎么搭建:从环境配置到本地运行

生活资讯7小时前发布 jdkjadmin
0 00
笑话网站源码怎么搭建:从环境配置到本地运行

笑话网站源码怎么搭建:从环境配置到本地运行

搭建一个笑话网站,不一定要先引入前端框架、用户系统和复杂的内容管理平台。对于练习 Web 开发、验证页面想法或制作个人作品集,一个基于 Python、Flask 和 SQLite 的小站已经够用:读者可以浏览笑话、按关键词搜索,也可以提交新内容。

本文从空目录开始,给出一套可在本地运行的示例。代码把网页和数据存储分开,并对表单输入做基本校验。需要先说明:这是一套适合学习和本地演示的起点,不是可直接承载公开投稿的生产系统。文末会说明哪些边界不能忽略。

一、适用场景与技术选择

这个示例适用于以下场景:

  • 学习 Flask 路由、表单处理和模板渲染。
  • 制作只在自己电脑上运行的笑话列表。
  • 验证搜索、分类和投稿流程,再决定是否扩展。
  • 演示 Python Web 应用如何使用 SQLite 保存数据。

示例使用 Flask 提供 HTTP 服务,使用 SQLite 保存笑话。SQLite 数据库是本地文件,不需要另行安装数据库服务;Flask 的 Jinja 模板默认会转义模板中输出的普通字符串,可以降低用户内容被当作 HTML 执行的风险。

如果目标是多用户公开投稿、多人协作审核、账号登录、长期运营或高并发访问,这个最小示例就不够了。届时还需要结合实际部署环境设计身份认证、权限控制、CSRF 防护、滥用限制、审核流程、备份和监控,不能只把本地应用直接暴露到互联网。

二、准备环境

需要安装 Python 3。Flask 支持的 Python 版本可能随 Flask 版本变化,安装时应以所选 Flask 版本的官方兼容说明为准。建议在项目目录创建虚拟环境,避免项目依赖影响系统 Python。

mkdir joke-site
cd joke-site

python -m venv .venv

激活虚拟环境后安装 Flask。Windows PowerShell 的激活命令与 macOS、Linux 不同:

# Windows PowerShell
.venv\Scripts\Activate.ps1

# macOS / Linux
source .venv/bin/activate

python -m pip install Flask

如果在 Windows 上遇到脚本执行策略限制,不要为了运行项目随意修改整台机器的安全策略;可以按组织管理要求调整当前用户策略,或使用命令提示符执行虚拟环境中的激活脚本。也可以不激活环境,直接调用虚拟环境里的 Python 和 pip。

项目目录整理如下:

joke-site/
├── app.py
├── templates/
│   └── index.html
└── static/
    └── style.css

下面依次创建三个文件。运行应用时,Flask 会从项目目录中的 templates 文件夹查找模板,并通过 url_for('static', ...) 提供静态文件。

三、编写 Flask 应用和 SQLite 逻辑

将以下代码保存为 app.py。应用启动时会创建 instance/jokes.sqlite3,并在数据库为空时写入几条示例笑话。SQLite 的问号占位符用于绑定 SQL 参数,避免把用户输入直接拼接进 SQL 语句。

from pathlib import Path
import sqlite3

from flask import Flask, flash, redirect, render_template, request, url_for

app = Flask(__name__, instance_relative_config=True)

# 本地演示用的随机密钥。公开部署时必须通过环境变量配置随机密钥。
app.config["SECRET_KEY"] = "local-demo-key-change-before-deploy"
# 限制单个请求的数据量;这是请求体限制,不等同于字段长度校验。
app.config["MAX_CONTENT_LENGTH"] = 16 * 1024

Path(app.instance_path).mkdir(parents=True, exist_ok=True)
DB_PATH = Path(app.instance_path) / "jokes.sqlite3"

CATEGORIES = ("日常", "程序员", "谐音")


def connect_db():
    connection = sqlite3.connect(DB_PATH)
    connection.row_factory = sqlite3.Row
    return connection


def init_db():
    with connect_db() as db:
        db.execute("""
            CREATE TABLE IF NOT EXISTS jokes (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                title TEXT NOT NULL,
                body TEXT NOT NULL,
                category TEXT NOT NULL,
                created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
            )
        """)

        count = db.execute("SELECT COUNT(*) FROM jokes").fetchone()[0]
        if count == 0:
            examples = [
                ("早起", "闹钟响了,我和它都觉得对方应该再努力一点。", "日常"),
                ("代码评审", "同事问代码为什么能运行,我说它暂时还没想明白。", "程序员"),
                ("冷笑话", "为什么书很安静?因为它有很多页(话)没说。", "谐音"),
            ]
            db.executemany(
                "INSERT INTO jokes (title, body, category) VALUES (?, ?, ?)",
                examples,
            )


@app.get("/")
def index():
    # 仅限制应用处理的查询字符串长度,不代表服务器收到请求前就拒绝了它。
    keyword = request.args.get("q", "").strip()[:80]
    category = request.args.get("category", "").strip()

    if category not in CATEGORIES:
        category = ""

    conditions = []
    parameters = []

    if keyword:
        conditions.append("(title LIKE ? OR body LIKE ?)")
        pattern = f"%{keyword}%"
        parameters.extend([pattern, pattern])

    if category:
        conditions.append("category = ?")
        parameters.append(category)

    sql = "SELECT id, title, body, category FROM jokes"
    if conditions:
        sql += " WHERE " + " AND ".join(conditions)
    sql += " ORDER BY id DESC LIMIT 100"

    with connect_db() as db:
        jokes = db.execute(sql, parameters).fetchall()

    return render_template(
        "index.html",
        jokes=jokes,
        keyword=keyword,
        selected_category=category,
        categories=CATEGORIES,
    )


@app.post("/submit")
def submit():
    title = request.form.get("title", "").strip()
    body = request.form.get("body", "").strip()
    category = request.form.get("category", "").strip()

    errors = []
    if not title or len(title) > 60:
        errors.append("标题不能为空,且最多 60 个字符。")
    if not body or len(body) > 500:
        errors.append("笑话内容不能为空,且最多 500 个字符。")
    if category not in CATEGORIES:
        errors.append("请选择有效分类。")

    if errors:
        for error in errors:
            flash(error, "error")
        return redirect(url_for("index"))

    with connect_db() as db:
        db.execute(
            "INSERT INTO jokes (title, body, category) VALUES (?, ?, ?)",
            (title, body, category),
        )

    flash("提交成功。", "success")
    return redirect(url_for("index"))


if __name__ == "__main__":
    init_db()
    # 仅供本机开发调试;不要把调试服务器当作生产服务器。
    app.run(host="127.0.0.1", port=5000, debug=True)

长度校验使用 Python 的 len(),限制的是 Python 字符串长度,不是 UTF-8 编码后的字节数,也不一定等同于人眼看到的字素数量。这里的 60 和 500 是应用自行设定的示例业务限制,不是 Flask 或 SQLite 的固有参数限制。请求体上限则是 16 KiB,用于限制整个请求体大小;它不能代替逐字段校验。

搜索条件使用了参数绑定,因此关键词不能借此注入 SQL。不过,LIKE 中的百分号 % 和下划线 _ 仍有通配符含义,输入这些字符可能匹配比字面字符更多的内容。这个示例接受这种搜索行为;如果产品要求严格按字面搜索,需要另外对通配符进行转义并设置相应的 ESCAPE 规则。

查询使用 LIMIT 100,因此页面最多展示 100 条匹配记录;示例没有分页。数据积累后应增加分页,并根据实际数据量评估索引。ORDER BY id DESC 按记录编号倒序排列,不代表按经过审核的发布时间排序。

四、编写网页模板

创建 templates/index.html。表单通过 POST 请求提交到 /submit;搜索表单通过 GET 请求提交关键词和分类。模板中没有使用 safe 过滤器,因此笑话标题和正文会按 Jinja 默认规则转义后显示。

<!doctype html>
<html lang="zh-CN">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>笑话小站</title>
  <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
<body>
  <main class="container">
    <header>
      <h1>笑话小站</h1>
      <p>浏览几则轻松的笑话,也可以提交一条。</p>
    </header>

    {% with messages = get_flashed_messages(with_categories=true) %}
      {% if messages %}
        <ul class="messages" aria-live="polite">
          {% for category, message in messages %}
            <li class="{{ category }}">{{ message }}</li>
          {% endfor %}
        </ul>
      {% endif %}
    {% endwith %}

    <section aria-labelledby="search-heading">
      <h2 id="search-heading">查找笑话</h2>
      <form method="get" action="{{ url_for('index') }}" class="search-form">
        <label>
          关键词
          <input type="search" name="q" maxlength="80" value="{{ keyword }}">
        </label>
        <label>
          分类
          <select name="category">
            <option value="">全部分类</option>
            {% for item in categories %}
              <option value="{{ item }}" {% if selected_category == item %}selected{% endif %}>
                {{ item }}
              </option>
            {% endfor %}
          </select>
        </label>
        <button type="submit">搜索</button>
      </form>
    </section>

    <section aria-labelledby="list-heading">
      <h2 id="list-heading">笑话列表</h2>
      {% if jokes %}
        {% for joke in jokes %}
          <article class="joke">
            <h3>{{ joke["title"] }}</h3>
            <p>{{ joke["body"] }}</p>
            <p class="category">分类:{{ joke["category"] }}</p>
          </article>
        {% endfor %}
      {% else %}
        <p>没有找到匹配内容。</p>
      {% endif %}
    </section>

    <section aria-labelledby="submit-heading">
      <h2 id="submit-heading">提交笑话</h2>
      <form method="post" action="{{ url_for('submit') }}">
        <label>
          标题(最多 60 个字符)
          <input name="title" maxlength="60" required>
        </label>
        <label>
          内容(最多 500 个字符)
          <textarea name="body" maxlength="500" rows="5" required></textarea>
        </label>
        <label>
          分类
          <select name="category" required>
            {% for item in categories %}
              <option value="{{ item }}">{{ item }}</option>
            {% endfor %}
          </select>
        </label>
        <button type="submit">提交</button>
      </form>
    </section>
  </main>
</body>
</html>

HTML 中的 maxlength 和 required 是浏览器端的便利校验,用户可以绕过它们直接构造 HTTP 请求,因此服务端也要校验。模板里的自动转义主要针对 HTML 上下文;如果将来把用户内容塞进 JavaScript、CSS 或 URL 等其他上下文,仍要使用适合该上下文的安全处理方式,不能认为“模板会转义”就能覆盖所有输出位置。

五、添加基础样式

创建 static/style.css。样式不是程序运行的必要条件,但能让本地页面更容易阅读。

:root {
  color-scheme: light;
  font-family: system-ui, sans-serif;
  background: #f5f6f8;
  color: #252a34;
}

* {
  box-sizing: border-box;
}

body {
  margin: 0;
  line-height: 1.6;
}

.container {
  width: min(760px, calc(100% - 32px));
  margin: 36px auto;
}

header,
section,
.joke {
  margin-bottom: 20px;
}

section,
.joke {
  padding: 18px;
  background: #fff;
  border: 1px solid #e2e5ea;
  border-radius: 10px;
}

label {
  display: block;
  margin: 12px 0;
}

input,
select,
textarea,
button {
  display: block;
  width: 100%;
  margin-top: 6px;
  padding: 10px;
  font: inherit;
}

button {
  cursor: pointer;
}

.category {
  color: #626b78;
  font-size: 0.9rem;
}

.messages {
  padding-left: 20px;
}

.error {
  color: #9d2424;
}

.success {
  color: #176b37;
}

@media (min-width: 600px) {
  .search-form {
    display: grid;
    grid-template-columns: 1fr 1fr auto;
    gap: 12px;
    align-items: end;
  }

  .search-form button {
    width: auto;
  }
}

六、启动并检查

确认虚拟环境已经激活,在项目根目录运行:

python app.py

终端显示 Flask 开发服务器正在监听后,在浏览器访问:

http://127.0.0.1:5000/

页面应显示三条示例数据。可以试着搜索关键词、切换分类,再提交一条新笑话并刷新页面。数据库文件位于 instance/jokes.sqlite3;停止程序后重新启动,已保存的数据仍会保留。若删除该数据库文件,应用下次启动会重新创建空库并写入示例数据。

当前代码只在直接执行 python app.py 时调用 init_db()。如果改为用 Flask CLI 或其他 WSGI 方式启动,需要在部署流程中单独确保数据库已经初始化,不能假设所有启动方式都会执行主程序中的初始化代码。

七、重要安全边界:本地演示不等于可公开投稿

1. 不要把 Flask 调试服务器当作生产服务器

代码绑定 127.0.0.1,只接受本机连接,并启用了 debug=True。调试模式适用于开发期间查看错误和自动重载,不适合公开部署;调试器可能暴露敏感信息。生产环境应关闭调试模式,使用适合部署环境的 WSGI 服务器和反向代理,并通过环境变量等方式提供随机密钥。不要把示例中的固定密钥用于正式站点。

2. 示例没有 CSRF 防护和投稿审核

网站的 POST 路由没有 CSRF 令牌,也没有登录、权限检查、速率限制或人工审核。任何能访问该路由的人都可能提交内容。仅在本机开发或可信的封闭演示环境中使用这条投稿路径;公开网站需要根据场景加入 CSRF 防护、滥用控制和发布前审核。仅有长度限制并不能阻止垃圾内容、冒充、骚扰或其他不适当投稿。

3. 自动转义不负责判断内容是否合适

Jinja 默认自动转义有助于防止普通字符串中的 HTML 被浏览器直接解析,但它不是内容审核系统,也不替代输出上下文检查。不要为了让用户输入显示“格式正常”而随意使用 |safe,或把原始用户输入拼接成 HTML。若计划支持富文本,应采用经过审查的安全清洗方案,并明确允许的标签和属性。

4. 请求限制和字段限制各有作用

MAX_CONTENT_LENGTH 限制的是整个请求体大小,字段长度校验限制的是业务输入长度,两者不能互相替代。搜索关键词在路由里被截取到 80 个 Python 字符,但这只是应用收到请求后的处理规则,并不等同于网络层的请求大小限制。若需要更严格的资源保护,还应结合部署服务器的请求体限制、超时和访问频率控制,并按实际环境测试。

5. SQLite 适合起步,但要考虑并发与备份

SQLite 适合本地开发、小型应用和结构简单的单机部署。它是文件数据库,不能只把应用代码复制到另一台机器就自动带走用户数据;需要规划数据库文件的位置、访问权限和备份恢复。多个进程或较高写入量下,应评估锁等待、并发写入和部署架构。需要多人协作运营或持续承载较大流量时,可以根据实际需求迁移到服务端数据库,并配套迁移、备份和恢复流程。

八、常见错误排查

  • 提示找不到 Flask:通常是安装依赖时和运行程序时用了不同的 Python。先激活项目虚拟环境,再执行 python -m pip install Flask 和 python app.py;也可以检查 python -m pip show Flask。
  • 提示找不到模板:检查目录名是否为 templates,以及 index.html 是否放在该目录下。模板文件不能放到 static 目录代替。
  • 样式没有加载:检查文件位置是否为 static/style.css,并确认模板使用了给出的 url_for('static', filename='style.css')。
  • 页面打开失败或端口被占用:确认程序仍在运行,并检查终端报错。若 5000 端口已被占用,可把 app.py 中的端口改为其他空闲端口,再访问对应地址。
  • 重启后数据不见了:确认查看的是当前项目对应的 instance/jokes.sqlite3,没有删除数据库,也没有从不同工作目录运行另一份项目。
  • 改了代码却没效果:确认编辑的是正在运行的项目副本。开发服务器可能自动重载,但文件位置、终端启动目录或浏览器缓存仍可能造成误判;出现错误时先查看运行 Flask 的终端输出。

九、后续可以怎样扩展

先把当前流程跑通,再按需求逐项扩展,比一开始引入复杂架构更容易定位问题。常见方向包括:给列表增加分页;增加随机展示;增加审核状态和管理页面;为投稿者添加账号与权限;在公开投稿前增加 CSRF 防护和频率限制;为数据库制定备份与恢复方案。

扩展时尤其要区分“能运行”和“适合公开运营”:表单能插入数据,不等于投稿系统已经具备抗滥用能力;模板能显示文本,也不等于任意 HTML 都能安全展示。先明确用户范围、内容规则、数据规模和部署方式,再补上对应的安全与运维措施,这样才能让一个可用的本地笑话站逐步变成可靠的网站。

© 版权声明

相关文章