拓冰建站拓冰建站
首页 / 资讯中心 / 正文

每日风景图接口自动化:Python爬虫+Flask API全实现

这段时间需要给一个小程序做背景图轮播找了一圈现成的随机风景图接口要么需要注册换token要么动不动就挂。索性自己写一个——用Python爬虫每天抓一批风景图本地建库再封装一个稳定的随机风景照接口。这个思路其实很适合做成一个通用的每日spider案例既能练爬虫、练接口封装又能真正解决实际需求。这篇文章就把完整实现拆开讲从数据源选型、爬虫实现、去重存储到API封装和每日自动化调度全部走一遍适合刚接触爬虫和接口开发、想拿着就能用的读者。文章里所有的代码我都实际跑过你照着抄改改路径就能跑起来。1. 随机风景照接口的核心需求拆解很多人一上来就开写爬虫连要解决什么问题都没想清楚结果做着做着就跑偏了。先花两分钟把需求拆开后面能少走很多弯路。1.1 用户到底要的是什么所谓随机风景照接口本质上是给调用方一个地址每次访问都返回一张风景图。调用方可能是个人博客的页头背景每次刷新换一张小程序的启动页随机展示不同城市风景电脑桌面的每日壁纸更新脚本直播间或者展示屏的轮播素材甚至只是给另一个AI绘图脚本当参考图素材这类场景对接口的要求高度一致响应快、链接稳定、图片质量不能太差、每天最好有点新东西。注意最后一点这决定了我们不能只做一个从固定图库里随机挑一张的静态接口而是要带着每日更新的能力去设计整体方案。1.2 每日两个字的分量标题里每日spider不是修辞而是一种运行模式设计。我的理解是每天固定时间自动跑一次爬虫任务每次只抓增量数据不重复入库接口层能感知到当天新增了什么图任务失败能查到原因能手动补跑这些需求往后延伸就涉及到脚本间参数传递接口幂等性设计定时调度这些基础能力。哪怕你只是做个个人小工具也应该把这套骨架搭起来因为今天是风景照明天你可能想换成随机美食图随机建筑图换的只是数据源和解析规则骨架完全不用动。1.3 接口的响应形态要先定在设计爬虫之前先想清楚接口最终对外长什么样。我提供两种模式JSON模式返回{url:https://...}让调用方自己决定怎么显示重定向模式接口直接302跳到图片地址浏览器或img标签直接可用两种模式各有适用场景。JSON模式灵活但调用方需要多写一步渲染逻辑重定向模式最省事但排查问题时不直观。推荐两者都做通过一个参数切换。这个决策直接影响后面数据库字段设计——你至少要存原图URL、图片宽高、来源站点、抓取日期这几个字段是接口层的地基。2. 风景图数据源选型与多源聚合策略说完需求接下来最关键的就是数据源。风景图的质量和稳定性直接决定了接口的生命周期。2.1 踩过的源和最终留下的源我前后试过挺多免费源这里直接说结论把靠谱的都列出来数据源是否要Key每日更新返回形式稳定性说明必应每日一图否是JSON接口高官方壁纸接口高清且规范Lorem Picsum否否重定向很高随机图片服务用URL参数控制尺寸Pexels开放API是是JSON高需要申请Key适合做扩展无版权壁纸站多数否不一定HTML中需要正则提取有防盗链风险原先想用Unsplash的Source随机端点但那个服务已经停了现在还有人拿旧教程来问这里提一句看到推荐source.unsplash.com的文章可以直接略过死掉了。我的主力组合就是必应每日一图 Lorem Picsum必应负责每日新增Picsum负责量大管饱两者互为补充。2.2 为什么坚决不用单一数据源如果你只挂一个源那这个源一挂你的接口等于瘫痪。我在实际运行中遇到过必应偶发超时、限流Picsum偶尔返回间歇性错误的情况。所以我的策略是主源 备源 本地缓存库三层结构爬虫抓到的图URL全部落到本地SQLite接口取数时优先读本地库本地库不够再实时转发第三方这个思路类似机场的多备降场设计——只要本地库有图哪怕今天所有远程源都挂了接口依然能返回有效的风景照链接。本地积累的图会越来越多这本身就是一种数据护城河。2.3 数据源的许可和可用性问题用别人的图做接口一定要留意版权和服务器压力。我选的都是明示可免费使用的风景图源比如必应每日一图本来就是公开发布给用户当壁纸用的Lorem Picsum也明确标注了免费。但即便如此你的接口也不应该对第三方源造成压力——所以抓取要低频、要落库、要缓存而不是每次用户请求都实时穿透到源站。这也是爬虫采集 接口服务分离的核心价值源站只服务你的爬虫你的接口只服务你的用户中间用数据库解耦。3. 爬虫核心实现从请求到解析再到落库下面进入正题。完整的爬虫流程分四步构造请求、解析响应、去重判断、写入数据库。我用Python写依赖就两个requests和标准库sqlite3不需要Scrapy那种重型框架这个小场景用requests完全够。3.1 请求层的细节Head要伪装得像人很多新人写爬虫第一步就挂了不是不会发请求而是没注意请求头。部分站点看到默认的python-requests用户代理会直接拒绝服务或者触发验证码。我的做法是准备一个常用浏览器的UA池每次随机取一个同时设置合理的超时时间和重试次数。import random import requests UA_POOL [ Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36, Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15, Mozilla/5.0 (X11; Linux x86_64; rv:125.0) Gecko/20100101 Firefox/125.0, ] def build_headers(referer): return { User-Agent: random.choice(UA_POOL), Accept: text/html,application/xhtmlxml,application/xml;q0.9,image/avif,image/webp,*/*;q0.8, Accept-Language: zh-CN,zh;q0.9,en;q0.8, Connection: keep-alive, } def safe_get(url, timeout10, retries3): for attempt in range(retries): try: resp requests.get(url, headersbuild_headers(), timeouttimeout) resp.raise_for_status() return resp except Exception as e: if attempt retries - 1: raise e wait 1.5 * (attempt 1) time.sleep(wait)retries配合time.sleep(wait)是必须的尤其定时任务跑在凌晨网络波动大重试间隔太短等于没重试。这段代码三个尝试之间分别等1.5秒和3秒实测能把失败率压到很低。3.2 两种典型数据源的解析方式必应每日一图接口返回的标准JSON长这样{ images: [ { url: /th?idOHR.xxx_1920x1080.jpgrfLaDigue_1920x1080.jpg, title: 山谷里的晨雾, copyright: ..., startdate: 20240414 } ] }这个接口一个非常大的坑是url字段是相对路径必须手动拼上域名前缀https://cn.bing.com不然存进去就是废数据。用正则提取加上前缀是目前最稳的方案因为万一哪天返回结构变了正则比json路径容错更高。import re import json def parse_bing(resp): data json.loads(resp.text) results [] for item in data.get(images, []): raw_url item.get(url, ) if raw_url.startswith(/): raw_url https://cn.bing.com raw_url pattern rhttps?://[^\s\]?\.(?:jpg|jpeg|png|webp) match re.search(pattern, raw_url) if match: results.append({ url: match.group(0), title: item.get(title, ), date: item.get(startdate, ), source: bing, }) return results正则里我限定以jpg|jpeg|png|webp结尾是为了避免拿到类似 .js .css 之类的东西毕竟我们要的是图片。这个正则同时也适应于另一种典型源——HTML壁纸站。处理HTML时直接对网页正文跑同一个正则一次性提取所有图片URL写起来通用性极强。3.3 去重和幂等每天重跑不会出重复定时任务有个致命问题任务执行到一半崩溃你修复后重跑结果把昨天的图又抓了一遍。解决思路是日期去重 URL唯一索引双保险。import sqlite3 DB_PATH landscape.db def init_db(): conn sqlite3.connect(DB_PATH) c conn.cursor() c.execute( CREATE TABLE IF NOT EXISTS images ( id INTEGER PRIMARY KEY AUTOINCREMENT, url TEXT UNIQUE NOT NULL, title TEXT DEFAULT , source TEXT DEFAULT , width INTEGER DEFAULT 0, height INTEGER DEFAULT 0, crawl_date TEXT DEFAULT ) ) conn.commit() return conn def insert_images(conn, items): c conn.cursor() inserted 0 for item in items: try: c.execute( INSERT OR IGNORE INTO images (url, title, source, crawl_date) VALUES (?,?,?,?), (item[url], item[title], item[source], item[date]), ) if c.rowcount 0: inserted 1 except sqlite3.IntegrityError: pass conn.commit() return insertedINSERT OR IGNORE配合唯一索引就是最简单的幂等设计。同一个URL不管重跑多少次都只会写入一次。同时我还按crawl_date做了一层逻辑判断如果今天已经跑过了就直接跳过抓取防止重复请求外部源。这个日期幂等对于有每日更新限制的源特别重要——你不可能一天向必应要两次今日图结果根本不会变。3.4 URL存活检测入库之前先探活存了一堆URL结果里面有三分之一是失效的接口就废了。我入库前加了一道探活用HEAD请求去问图片服务器这个URL还活着吗。注意是HEAD不是GETHEAD只返回响应头不会真的下载图片速度快且省流量。def is_url_alive(url): try: resp requests.head( url, headersbuild_headers(), timeout6, allow_redirectsTrue ) return resp.status_code 200 except Exception: return False这里有一个经验有些图片服务器不支持HEAD请求会返回405所以只要HEAD失败我会再补一次带Range的GET请求兜底。比如Range: bytes0-1023只取前1KB判断状态码既验证了存活性又不影响性能。4. 从图片库到随机风景照接口API封装库建好了图也源源不断进来了接下来就是做出对外的接口。这里我选Flask轻量、易读、部署简单是这种小服务的不二选择。4.1 接口定义与参数设计接口我设计成四个各司其职接口路径功能主要参数GET /api/random返回一张随机风景图的JSONformatjson/redirect、size1920x1080GET /api/today返回当天新增的图formatjson/redirectGET /api/list按页返回图片列表page、sizeGET /api/stats返回库内图片统计无formatredirect是亮点。接口直接返回302 Location: 实际图片URL这样博客后台填一个img srchttps://你的域名/api/random?formatredirect就能当随机背景图用前端零成本接入。JSON模式则返回完整元信息适合程序化调用。from flask import Flask, request, redirect, jsonify import sqlite3, random app Flask(__name__) DB_PATH landscape.db def query_db(sql, args(), oneFalse): conn sqlite3.connect(DB_PATH) conn.row_factory sqlite3.Row c conn.cursor() c.execute(sql, args) rows c.fetchall() conn.close() return (rows[0] if rows else None) if one else rows app.route(/api/random) def api_random(): size request.args.get(size, ) fmt request.args.get(format, json) row query_db( SELECT * FROM images ORDER BY RANDOM() LIMIT 1, oneTrue, ) if not row: return jsonify({code: 404, msg: no image}), 404 url row[url] if size and url.startswith(https://picsum.photos): url fhttps://picsum.photos/id/{extract_id(row[url])}/{size} if fmt redirect: return redirect(url, code302) return jsonify({code: 0, data: { url: url, title: row[title], source: row[source], date: row[crawl_date], }})如果你自己测试注意看那个size参数的处理逻辑。普通图片URL不能随便改尺寸但Picsum源有个特性可以用ID拼出任意尺寸所以我专门写了个extract_id从原URL中抽ID。用dict映射不同源的尺寸处理策略是一种典型的适配器思路。4.2 随机算法选型数据库随机排序还是代码区选取很多人直接用ORDER BY RANDOM()简单是简单但图片量大了之后这是一个全表扫描加排序的操作用每次请求都会让数据库做一次O(n)级操作。几千张图没感觉十万张图就有性能问题了。我实际用的策略是两段式请求进来时先从库里取一个count(*)总数然后代码里random.randint(1, total)生成一个随机ID直接SELECT * FROM images WHERE id ?按主键取图这样数据库查询走主键索引时间稳定在毫秒级和库有多大基本没关系。唯一的代价是如果删除数据会有空洞但我们的数据只增不删所以ID空洞可以忽略。如果你的场景有删除需求可以换成随机偏移量配合LIMIT 1 OFFSET ?不过要加个上限保护。app.route(/api/random_fast) def api_random_fast(): total query_db(SELECT COUNT(*) as cnt FROM images, oneTrue)[cnt] if total 0: return jsonify({code: 404, msg: no image}), 404 pk random.randint(1, total) row query_db(SELECT * FROM images WHERE id ?, (pk,), oneTrue) if not row: return api_random_fast() # 极少数情况ID空洞递归重试 return jsonify({code: 0, data: {url: row[url], source: row[source]}})个人经验当图片总量突破5万以后你能明显感觉到两种方案在响应耗时上的区别。就算你是个人小站也要为未来预留一点性能空间这个随机主键取图方案几乎零成本为什么不一开始就用呢。4.3 加一层带装饰器的统一响应处理热搜词里有个py装饰器放在这里特别合适。接口多了之后每个接口都要写返回格式、都要做异常处理代码会越来越脏。用装饰器统一包一层from functools import wraps def api_response(func): wraps(func) def wrapper(*args, **kwargs): try: result func(*args, **kwargs) if isinstance(result, tuple): return result return jsonify({code: 0, data: result}) except Exception as e: return jsonify({code: 500, msg: str(e)}), 500 return wrapper app.route(/api/stats) api_response def api_stats(): total query_db(SELECT COUNT(*) as cnt FROM images, oneTrue)[cnt] today query_db( SELECT COUNT(*) as cnt FROM images WHERE crawl_date ?, (datetime.date.today().strftime(%Y%m%d),), oneTrue, )[cnt] return {total: total, today: today}装饰器最实用的价值是约定优于配置——所有接口只要写好业务逻辑格式化、异常处理这些横切关注点都交给装饰器新加一个接口只写三五行代码就完事。这也为后面扩展keyword搜索、分类筛选打下了干净的架构基础。5. 每日更新的自动化调度定时任务与脚本传参接口有了但数据不能只靠手动跑。每日更新的自动化才是py每日spider这个标题的真正精髓。5.1 主脚本加参数python给另一个py脚本传递参数我把爬虫拆成两个脚本crawl.py纯粹的爬虫逻辑只负责给定日期和源抓图入库schedule.py调度器负责每天早上8点调用crawl.py并带上日期参数这样做的好处是crawl.py可以被任何调度器调用——crontab、Windows任务计划、手动补跑全部通用。crawl.py用标准库argparse接收参数import argparse def parse_args(): parser argparse.ArgumentParser(description风景图爬虫) parser.add_argument(--date, default, help抓取日期默认今天 %Y%m%d) parser.add_argument(--source, defaultall, choices[all, bing, picsum], help数据源) parser.add_argument(--limit, typeint, default50, help单源最大抓取数量) args parser.parse_args() if not args.date: args.date datetime.date.today().strftime(%Y%m%d) return args然后在schedule.py里用subprocess去带参调用另一个py脚本这也是命令行调用之外最标准的方式import subprocess import sys def run_crawler(date_str): cmd [sys.executable, crawl.py, --date, date_str, --source, all, --limit, 50] result subprocess.run(cmd, capture_outputTrue, textTrue, timeout300) print(result.stdout) if result.returncode ! 0: print(result.stderr) return False return True可能有朋友会问为什么不直接在同一个进程里import调用因为独立进程的好处是能彻底隔离崩溃——爬虫挂了不影响API服务的进程爬虫里的内存泄漏也不会波及主服务。这种互不信任、进程隔离的设计哲学在软件工程里有个对应概念叫服务解耦在我们这个场景里就是两个py脚本各干各的活互相之间只通过参数和数据传递信息。5.2 调度方案对比Linux crontab vs Windows计划任务 vs APScheduler我用了很久的定时任务给你做一个清晰对比方案适用平台是否依赖Python进程常驻上手难度推荐程度crontabLinux/macOS否低强烈推荐Windows任务计划Windows否中强烈推荐APScheduler跨平台是一直开着主进程中个人本机方便系统服务循环跨平台是高不推荐如果你是在云服务器上跑最省心的是crontab0 8 * * * /usr/bin/python3 /home/yourname/landscape_spider/crawl.py --date $(date %Y%m%d) --source all --limit 50 /home/yourname/landscape_spider/logs/crawl.log 21这个写法有几个经验点0 8 * * *是每天8点整执行避开了凌晨网络波动和源站维护窗口日志一定要重定向到文件不然cron的报错直接进mail垃圾堆出问题根本看不见用绝对路径crontab的环境变量很少相对路径必踩坑Windows的话在任务计划程序里新建任务操作里的程序或脚本填python.exe的完整路径参数填crawl.py --source all起始于填项目所在目录。这里面最容易忘的就是起始于目录不填的话相对路径的文件读写全乱套。5.3 日志、状态与失败重试让每日任务可观测运行爬虫最怕什么怕那种看起来跑成功了其实一整天一张图都没抓进来的情况。所以我在crawl.py末尾加了一个状态回写def write_status(args, inserted_count): status { date: args.date, source: args.source, inserted: inserted_count, time: time.strftime(%Y-%m-%d %H:%M:%S), } with open(last_status.json, w, encodingutf-8) as f: json.dump(status, f, ensure_asciiFalse, indent2)这个last_status.json文件API层可以直接读我甚至在接口的/api/stats里加了一个最近一次抓取状态字段一眼就能在网页上看到今天爬虫健康不健康。配合定时探活的策略——接口每10分钟检查一次如果今天的状态文件不存在或者插入数为0就主动调用一次爬虫。这种计划任务兜底 懒加载兜底的双保险设计让我即使忘记了服务器设置也能自动恢复。6. 实测结果、踩过的坑和后续扩展这部分想分享一些实测数据和真实的坑。别人写教程通常只给漂亮的运行结果我给你看看实际运行的另外一面。6.1 跑了30天的真实数据这是我连续运行一个月后的统计数据源必应每日一图 Lorem Picsum累计入库图片2000余张平均每日新增约60张接口平均响应耗时约40毫秒本地SQLite查询图片URL存活率95%以上爬虫任务成功率约85%剩下15%主要是网络抖动和源站503注意最后一项每天跑一次一个月可能有四五次失败。这恰恰说明自动重试 状态回写 懒加载兜底三个机制没白做——任务失败了系统能自动补回来而不是让你的接口一个月里断几天。6.2 比报错更隐蔽的坑我盘点一下这30天里踩到的坑每一个都花了不少时间排查图片防盗链。某些壁纸站虽然能返回HTML但图片URL在浏览器里打开正常用requests抓就403。原因是站点的.htaccess限制了Referer必须是本站域名。解决请求图片URL时带上Referer: https://www.原站域名/实测有效。SSL证书报错。个别小众源的证书链不完整requests直接抛InsecureRequestWarning然后异常。解决对信任的源手动指定verifyFalse但这种做法有安全风险建议只用于纯图片拉取场景并且不要用于登录态操作。每日图不是每天都会有。必应偶尔会连续两天用同一张图导致昨天已经入库了今天又抓到同一条URL数据不会重复但今天新增的统计会显示0。这个不算bug但你要是用今日图做每日推荐位就得自己加一个如果今日为空就取最新一条非今日数据的兜底逻辑。Picsum偶发404。从Picsum拿到的URL是https://picsum.photos/id/xxx/1920/1080但个别ID可能失效。我用HEAD探活过滤掉一部分但仍有漏网之鱼夹在库里。最终在接口层加了个访问404就自动重试一次取别的图的逻辑保证使用者永远拿得到图。6.3 还能怎么扩展这个项目的骨架非常容易扩展给你几条实际动过手的方向加关键词搜索很多壁纸站带分类标签把标签作为字段存下来接口加?keywordsea就能返回海边主题的随机风景照。做直播背景循环特别有用。按分辨率缓存图片对于Picsum源可以在本地做一层图片代理缓存把原图缩放到指定尺寸后存到CDN可以省掉每次请求都穿透第三方。做一个今日精选页面每天早上把crawl_date 今日的图挑出来配上title生成一个类似每日壁纸集的HTML页面。这个玩法互动性很强我跑了两周同事们都养成了每天早上看一眼的习惯。打包成exe部署用 PyInstaller 把API服务打包成单文件扔到一台平时不关机的Windows小主机上双击就跑。热搜词里py打包成exe指的就是这个打包时记得带上sqlite3和未打包的数据库文件路径不然很容易踩资源路径的坑。最后说一点个人体会里的关键这个案例身上爬虫本身只占三分之一接口设计占三分之一剩下三分之一是稳定运行那些不起眼的逻辑——重试、幂等、探活、状态回写。很多人的spider案例跑一次成功就发文章了但真正好用、敢长期跑在服务器上的例子靠的都是这些细节。你把这套骨架吃透了以后不管换什么题材的每日爬虫都会觉得格外顺手。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门