盯着闲鱼上几个标品来回切窗口刷新,价格忽上忽下,手头表格却迟迟不敢更新。利润刚冒头,转眼就被更低价砸穿。这种憋屈,干过搬砖的都懂。
做二手“搬砖”的人最怕两件事:信息滞后和算错账。一个SKU在不同城市、不同成色、不同卖家手里能差出几十上百块。你这边还在逐个点开宝贝复制标题去比价,那边已经有人把价格调低抢走成交。等你算完一轮,机会早凉了。
闲鱼的商品价格并不是一条平稳曲线。促销、议价、补库存、同城急出,都会让价格在短时间内上下抖动。传统做法是把链接丢进表格里,每天固定时间扫一遍。问题是,真正的差价窗口往往只有几分钟到几小时,且随机出现。靠人工刷页面不仅累,还容易漏掉突发性的低价波段。
平台不会直接告诉你“这件东西过去一个月的成交区间是多少”,也不会标明“当前报价相对历史低位高出多少”。于是很多人只能凭记忆或者粗略搜索来判断值不值得出手。可同一型号在不同配置、不同地区、不同成色下的成交价差异很大,光看均价容易被误导。真正该盯的是同条件下的历史价差,以及当下报价与这条曲线的偏离度。
市面有些脚本能把价格爬下来,但多数只是导出一堆CSV;还有些监控面板功能齐全,却笨重得像企业级BI。你要的不是一张大屏,而是能在浏览器里随手打开、看到关键节点被自动圈出来的轻量视图。最好能实时渲染,又能标注出“高利润搬砖目标”的位置,而不是让你自己去翻折线图猜哪一段值得操作。
// 最小可用的数据结构示意
{
sku: 'xiaomi-note-12-8-128',
region: 'shanghai',
currentPrice: 980,
historicalLow: 950,
historicalHigh: 1050,
marginLabel: 'high-profit' // auto marked
}
用Vue3 + ECharts实时渲染价格曲线
既然前面说了数据结构和标记逻辑,那总得有个东西把它画出来。不能用Excel糊弄,也不能在控制台打log看数字跳。得有一个你打开浏览器就能扫一眼、看到红绿箭头和折线拐点的视图。我选了Vue3组合式API配合ECharts 5来做这件事。不是ECharts不可替代,而是它社区生态够稳,闲鱼价格这种时序数据,折线图加散点标注正好是它的看家本事。
Vue3的setup语法糖写起来比Options API顺手太多,尤其当你需要管理一堆定时器、WebSocket连接和图表实例的时候——这些东西全扔在setup里用ref和reactive兜着,逻辑内聚,不像以前那样散落在data、methods、watch三个区域来回找。ECharts 5我选的是按需引入,只注册LineChart、、这些,打包体积能压到200KB以内。Axios做定时轮询,闲鱼接口没有公开的WebSocket通道,那就用setInterval每30秒拉一次最新价格,配合ETag头判断数据是否有更新,避免重复渲染。
第一次跑起来的时候,那个折线图每隔半分钟就抖一下,像心跳监测仪的波形。挺上头的。
我写了一个的composable,接收一个SKU对象,返回图表实例和当前标注点。这个Hook内部维护了数组和的引用,并且自动在组件挂载时初始化ECharts,卸载时调用dispose。核心逻辑是:每次新数据推入数组后,先调用找出历史低点和高点,再通过setOption更新图表。
// usePriceChart.ts 核心片段
import * as echarts from 'echarts/core'
import { LineChart, MarkPointComponent, TooltipComponent } from 'echarts/components'
import { CanvasRenderer } from 'echarts/renderers'
echarts.use([LineChart, MarkPointComponent, TooltipComponent, CanvasRenderer])
export function usePriceChart(containerRef: Ref<HTMLElement | null>, sku: string) {
const priceHistory = ref<PricePoint[]>([])
let chartInstance: echarts.ECharts | null = null
const initChart = () => {
if (!containerRef.value) return
chartInstance = echarts.init(containerRef.value)
}
const updateChart = () => {
if (!chartInstance) return
const markPoints = calculateMarkPoints(priceHistory.value)
chartInstance.setOption({
xAxis: { type: 'time' },
yAxis: { type: 'value' },
series: [{
type: 'line',
data: priceHistory.value.map(p => [p.timestamp, p.price]),
markPoint: {
data: markPoints
}
}]
})
}
onBeforeUnmount(() => chartInstance?.dispose())
return { priceHistory, initChart, updateChart }
}
这里有个坑——ECharts的markPoint数据格式要求每个点必须有coord数组,value和itemStyle是可选。如果你直接传一个{ name: '历史最低', coord: [timestamp, price] }就行了。我一开始没注意coord的字段名写成了value,结果标注点死活不出来,调试了半小时才发现文档里标的明明是coord,不是value。
图表只画折线,但页面下方还得有一个历史成交明细列表,方便你点开某一条记录看当时的成交价和成色描述。这个列表如果一次性渲染几千条,哪怕你用v-for加key,DOM节点数也够呛。我试过一次性加载3000条,页面滚动直接掉到20帧,卡得鼠标指针都飘。
解决方案是用这个库,它只渲染可视区域内的DOM,滚动时动态替换。不过它的VirtualList组件要求列表项高度固定,闲鱼商品描述长短不一,我折中了一下:每行固定给60px,超出部分用ellipsis截断,鼠标悬浮时弹tooltip显示完整文本。这样既保证了虚拟滚动的性能,又不丢失信息。
这个组件在列表项超过500条时效果显著,页面帧率稳定在55fps以上。配合ECharts的dataZoom组件——用户可以用滑块拖拽查看某段时间内的价格细节——整个页面终于不像是大学期末作业了。
最后补一句:图表实例的resize一定要监听,不然浏览器窗口缩放后图表会变形。这个坑我已经替你们踩过了。
自动标注高利润搬砖目标:历史价差算法实战
前面我们把价格曲线画出来了,但真正决定要不要“搬一次砖”,还得看数字:当前价相对于历史成交的高低,以及一旦买入卖出能落袋多少。这一章把“价差计算”和“自动标注”拆开讲,顺便交代我在调试时被空值坑的那一下。
最朴素的思路是拿到一段历史成交价的最高值 maxPrice、最低值 minPrice,再用 currentPrice 去套公式。可现实里数据会缺斤少两:某天没成交,爬虫漏了一条,或者接口返回 null。我的 computed 里因此多了几行防御性写法。
const history = computed(() => rawHistory.filter(Boolean).map(h => ({
timestamp: +h.timestamp,
price: parseFloat(h.price)
})).sort((a, b) => a.timestamp - b.timestamp))
const minItem = computed(() => {
const arr = history.value
if (!arr || !arr.length) return null
return arr.reduce((acc, cur) => acc.price < cur.price ? acc : cur)
})
const maxItem = computed(() => {
const arr = history.value
if (!arr || !arr.length) return null
return arr.reduce((acc, cur) => acc.price > cur.price ? acc : cur)
})
const profitPct = computed(() => {
if (!minItem.value || !maxItem.value) return 0
// 以“低买高卖”的最大空间计
return ((maxItem.value.price - minItem.value.price) / minItem.value.price) * 100
})
页面刚打开时 history 为空很正常,别让 reduce 直接报错;另外 price 有时带小数点后两位,parseFloat 比 Number 更稳当一点。
ECharts 的 markPoint 我一开始用得顺手,后来发现它默认会把标注挤到图形边缘,离实际发生的时间点差半格。换成 markLine 并用 xAxis 索引做锚点后,位置才贴肉。下面这段配置挂在 series 上,配合一个 visible 判断,只在有有效极值时显示。
markLine: {
symbol: ['circle', 'arrow'],
lineStyle: { color: '#1890ff' },
data: [
{ type: 'min', name: '历史最低', label: { formatter: '¥{c}' } },
{ type: 'max', name: '历史最高', label: { formatter: '¥{c}' } }
]
},
emphasis: { focus: 'series' }
为了不把箭头堆满屏,我还加了一层阈值:只有当 profitPct 超过用户设定的最小利润百分比时,才让这两根线露面。顺便把提示框 tooltip 的 formatter 改了,让它同时给出“若现在买入、未来按历史最高价卖出”的期望收益区间。
我把阈值做成一个范围滑块,放在图表右上角的工具栏里,v-model 绑着一个 minProfitPercent。每当它变动,就触发一次 filter,低于这条线的标注立刻灰掉或隐藏。这么做的好处是:你不会因为一时冲动去搬那些只有三五个点利润的大件。
顺带一提,别忘了监听窗口缩放后的 resize,不然 ECharts 在高分屏上折线会被压扁。这个坑我已经替你们踩过,别学我偷懒。
我用最近三个月的十来个品类做了回测:每次标注出现“历史最低”附近且利润超阈值时,记录当日买入并在一周内按历史最高价卖出的收益中位数。结果大概在 12% 上下,比无脑跟风高出一倍还拐弯。当然,这只是实验室数字,真实交易还得扣运费和平台手续费。
调参时我曾把阈值一路拉到 30%,标注几乎绝迹;降到 8% 又回到满屏红绿点。最终保留默认 10% 是个折中:既能筛掉鸡肋,又不过分苛刻。你也可以根据品类弹性改动,不必一条标准吃天下。
从监控到决策:打造完整的搬砖辅助工具
图表和标注做得再花哨,如果数据要手动录入、商品得一个个去页面翻,那它就是个玩具。工具最终要落地到每天的使用里,就得把“监控—通知—导出—扩展”这条链路走通。我花了一个周末把前面几章的零散组件拼成了完整的搬砖辅助工具,大概聊聊取舍。
最初想过用 IndexedDB 存历史价格,后来发现对于个人脚本文具来说,localStorage 完全够用。每个商品存一个 key-value,value 是 JSON 序列化的数组:itemId+name+price+timestamp。Vue 3 的 watch 配合 watchEffect 做自动持久化,页面关闭前最后一次价格变动不会丢。
// store/useWatchList.js
import { defineStore } from 'pinia'
import { ref, watch } from 'vue'
export const useWatchList = defineStore('watchList', () => {
const items = ref(JSON.parse(localStorage.getItem('xianyu_watchlist') || '[]'))
watch(items, (val) => {
localStorage.setItem('xianyu_watchlist', JSON.stringify(val))
}, { deep: true })
function addItem(item) {
if (!items.value.find(i => i.id === item.id)) {
items.value.push({ ...item, createdAt: Date.now() })
}
}
function removeItem(id) {
items.value = items.value.filter(i => i.id !== id)
}
return { items, addItem, removeItem }
})
删除时加了个二次确认弹窗,不然误操作清空了收藏夹,得重新爬历史数据,那个代价就大了。
当检测到某商品当前价低于历史最低价且利润阈值达标时,我用了 推送桌面通知。这里有个坑:Vue 3 的 onMounted 里直接调 new Notification() 会报“请先请求权限”。得先跑一遍 Notification.requestPermission(),而且最好放到用户点击“开启推送”的回调里,否则 Chrome 会判定为“非用户手势触发的通知”直接忽略。
function requestNotifyPermission() {
if (!('Notification' in window)) return
if (Notification.permission === 'default') {
Notification.requestPermission()
}
}
function pushAlert(item, currentPrice, targetPrice) {
if (Notification.permission === 'granted') {
new Notification('闲鱼低价提醒', {
body: `${item.name} 当前 ¥${currentPrice},低于目标 ¥${targetPrice},利润约 ${((targetPrice - currentPrice) / currentPrice * 100).toFixed(1)}%`,
icon: '/icon.png'
})
}
}
邮件推送我偷懒了,直接接了个 mailto: 链接,点击后自动填充标题和商品信息。真要用邮件还得搭 SMTP 服务,个人用户没必要折腾。
图表上的标注再直观,真要写月度复盘报告,还是得把原始数据倒出来。用了 file-saver 加 papaparse 把价格序列转成 CSV:第一列时间戳,第二列价格,第三列标注类型(min/max/profit)。
import { saveAs } from 'file-saver'
import Papa from 'papaparse'
function exportCSV(itemId) {
const item = items.value.find(i => i.id === itemId)
if (!item || !item.priceHistory?.length) return
const csv = Papa.unparse(item.priceHistory.map(p => ({
时间: new Date(p.timestamp).toLocaleString(),
价格: p.price,
标注: p.annotation || ''
})))
const blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8;' })
saveAs(blob, `${item.name}_价格历史.csv`)
}
加 \uFEFF 是因为 Excel 打开 CSV 时,没有 BOM 头的中文会乱码。这个坑我已经帮你们填了。
目前的组件只针对闲鱼,但数据结构其实通用:商品 ID、名称、价格、时间戳、平台标识。我在 Pinia store 里预留了一个 platform 字段,未来接入转转、拍拍只需新增一个 Adapter 类,实现 fetchPrice(itemId) 和 parseList(html) 两个方法即可。插件接口还没抽象得太复杂,一个简单的 registerPlatform(name, adapter) 函数就够。
工具做到这一步,其实已经脱离了“看曲线猜走势”的玄学阶段。屏幕上红绿标注不再是静态的图,而是每天自动更新的操作信号。至于要不要跟着信号下手,那是交易纪律的事了,工具只负责把数据摆在你面前,不带情绪。
数据结构搭好之后,页面一跑起来,最先打脸的是长度破百的列表。DOM 一多,别说滑动,连打字都掉帧。我先把锅甩给渲染逻辑,随后老老实实上了 vue-virtual-scroll-list,只让可视区那十几行真实存在,其余全是占位。
vue-virtual-scroll-list 很好用,但必须给每一项固定高度。懒加载图片一进来,尺寸还没拿到,滚动偏移就飘了。解决办法是在 Image 组件上挂 load 事件,回传自然高度,再写一个 Map 缓存住,防止反复触发 re-render。
<template>
<VirtualList
:data="visibleItems"
:item-height="cache.get(item.id)?.height || 80"
@scroll-end="loadMore"
/>
</template>
<script setup>
import { ref, onMounted } from 'vue'
const cache = ref(new Map())
function onImgLoad(id, h) {
if (!cache.value.has(id)) cache.value.set(id, { height: h })
}
</script>
价格曲线每秒都在抖,如果 setOption 不带参数,动画会越积越多,CPU 直奔 90%。我把 sampling 设为 'lttb',并强制 notMerge,告诉它只保留最近 500 个点。配合 requestAnimationFrame 节流,GPU 温度终于降下来。
chart.value.setOption({
series: [{ data: newPoints, type: 'line', sampling: 'lttb' }],
notMerge: true
}, false, true)
切到别的商品详情,定时器还在后台跑,memory 涨了就不回来。在 onBeforeUnmount 里 dispose 掉 ECharts 实例,再把 interval 清空,顺手把 chartRef 置 null,浏览器标签开一天也不觉得发烫。
移动端触屏惯性滚动总是比鼠标滚轮慢半拍,折腾了好几次才找准手感。touchmove 的 throttle 间隔调低了一点,virtual list 的 buffer 索性从 100 拉到 200,提前预判滑动方向,手指刚离开屏幕,列表基本已经停稳。横屏状态下顺手把图表工具栏挪到顶部——省得用户还得腾出另一只手去够。
项目能在本地跑通只算完成了一半,剩下那半是让它老老实实挂在线上,天天替你盯着闲鱼的价格波动。
脚手架用 create-vite 一把梭,模板选 vue-ts,省得自己配 tsconfig。唯一要手动加的是 @types/node,不然 path 会报红。
npm create vite@latest xianyu-monitor -- --template vue-ts
cd xianyu-monitor
npm install && npm install -D @types/node
路由扔给 Vue Router 4,状态管理直接 Pinia 一把梭。API 层拿 axios 简单包了一层,baseURL 先怼到本地 mock 服务。开发体验嘛,俩终端同时跑——左边 Vite 热更新飞起,右边 JSON Server 伺候着,跨域?Vite 的 proxy 配一行就完事,不用折腾 nginx。
// vite.config.ts
export default defineConfig({
server: {
proxy: {
'/api': 'http://localhost:3000'
}
}
})
闲鱼没有开放接口,真实场景得靠爬虫,但本地开发用 JSON Server 模拟就够了。一个 db.json 文件存着商品列表和价格历史,路由规则跟 RESTful 风格对齐。
部署选 Vercel 或者 Netlify 都行,静态文件加一个 vercel.json 重定向所有路由到 index.html,避免 SPA 刷新 404。后端 mock 就不部署了,线上环境直接连爬虫服务。
工具跑通那天,最爽的倒不是利润数字,是早起一开页面,扫两眼那些自动标红的商品。心里踏实了。以前得在闲鱼 app 里一个个翻、一个个记——说实话,那活儿干久了真烦。这回好了,机器替我盯着,我只管判断要不要下手。
标注逻辑按个人经验调整,不构成交易建议。
评论