2025-06-05 04:26:20
摄影:产品经理
简单做个家宴
我们经常让大模型返回Markdown格式的文本,然后通过Python的markdown
库把文本渲染成HTML。
但不知道大家有没有发现,大模型返回的Markdown并不是标准的Markdown。特别是当返回的内容包含列表时,大模型返回的内容有问题。例如下面这段文本:
1 |
**关于这个问题,我有以下看法** |
你粗看起来没有问题,但当你使用markdown
模块去把它渲染成HTML时,你会发现渲染出来的结果不符合你的预期,如下图所示:
这是因为标准的Markdown对换行非常敏感,列表项与它上面的文本之间,必须有一个空行,才能正确解析,如下图所示:
不仅是空行,还有多级列表的缩进问题。标准Markdown的子列表项缩进应该是4个空格,但大模型返回的子列表缩进经常只有3个空格,这就导致解析依然有问题。如下图所示:
而且这个空行问题和缩进问题,我尝试过反复在Prompt里面强调,但大模型依然会我行我素,无论是国产大模型还是Claude或者Gemini 2.5 Pro这些最新大模型,都有这个问题。
我曾经一度被憋得没办法,让大模型给我返回JSON,我再写代码把JSON解析出来手动拼接成标准Markdown。
后来,我发现主要的问题还是Python的markdown
库对格式要求太严格了,其实换一个更宽容的库就可以解决问题。于是我找到了mistune
这个库。使用它,直接就解决了所有问题。如下图所示:
mistune
的用法非常简单:
1 |
import mistune |
并且它天然支持数学公式、脚注等等高级语法。更多高级操作,可以查看它的官方文档
2025-05-27 05:20:33
摄影:产品经理
韩国章肥虾。
在使用Scrapy的时候,我们可以通过在pipelines.py里面定义一些数据处理流程,让爬虫在爬到数据以后,先处理数据再储存。这本来是一个很好的功能,但容易被一些垃圾程序员拿来乱用。
我看到过一些Scrapy爬虫项目,它的代码是这样写的:
1 |
... |
这种垃圾代码之所以会出现,是因为有一些垃圾程序员想偷懒,想复用Pipeline里面的代码,但又不想单独把它抽出来。于是他们没有皱褶的脑子一转,想到在Scrapy里面从数据库读取现成的数据,然后直接yield
出来给Pipeline。但因为Scrapy必须在start_requests
里面发起请求,不能直接yield
数据,因此他们就想到先随便请求一个url,例如百度,等Scrapy的callback进入了parse
方法以后,再去读取数据。
虽然请求百度,不用担心反爬问题,响应大概率也是HTTP 200,肯定能进入parse
,但这样写代码怎么看怎么蠢。
有没有什么办法让代码看起来,即便蠢也蠢得高级一些呢?有,那就是发送假请求。让Scrapy看起来发起了HTTP请求,但实际上直接跳过。
方法非常简单,就是把URL写成:data:,
,注意末尾这个英文逗号不能省略。
于是你的代码就会写成:
1 |
def start_requests(self): |
这样写以后,即使你没有外网访问权限也没问题,因为它不会真正发起请求,而是直接一晃而过,进入parse
方法中。我把这种方法叫做发送假请求。
这个方法还有另外一个应用场景。看下面这个代码:
1 |
def start_requests(self): |
假如你需要让爬虫每分钟监控一个URL,你可能会像上面这样写代码。但由于Scrapy是基于Twisted实现的异步并发,因此time.sleep
这种同步阻塞等待会把爬虫卡住,导致在sleep的时候,parse
里面发起的子请求全都会被卡住,于是爬虫的并发数基本上等于1.
可能有同学知道Scrapy支持asyncio
,于是想这样写代码:
1 |
import asyncio |
但这样写会报错,如下图所示:
这个问题的原因就在于start_requests
这个入口方法不能使用async
来定义。他需要至少经过一次请求,进入任何一个callback以后,才能使用async
来定义。
这种情况下,也可以使用假请求来解决问题。我们可以把代码改为:
1 |
def start_requests(self): |
这样一来,使用了asyncio.sleep
,既能实现60秒请求一次,又不会阻塞子请求了。
当然,最新版的Scrapy已经废弃了start_requests
方法,改为start
方法了,这个方法天生就是async方法,可以直接在里面asyncio.sleep
,也就不会再有上面的问题了。不过如果你使用的还是老版本的Scrapy,上面这个假请求的方法还是有点用处。
2025-05-07 07:34:23
摄影:产品经理
回锅肉
当我们采购数据集时,有时候供应商会以JSON Lines的形式交付给我们。这种格式,本质上是文本格式,它每一行是一个JSON。例如,供应商给我们了一个文件小红书全量笔记.json
文件,我们可以使用如下Python代码来一行一行读取:
1 |
import json |
这个格式的好处在于,每一次只需要把少量内容读取到内存中。即便这个文件有1TB,我们也可以使用一个4GB内存的电脑来处理。
今天出了一个乌龙事件,某数据供应商在给我数据的时候,说的是以JSON Lines格式给我。但我拿过来解压缩以后一看,100GB的文件,里面只有1行,如下图所示:
也就是说,他用的是一个超大JSON直接导出给我,并没有使用JSON Lines格式。正常情况下,如果我要直接解析这个数据,需要我的电脑内存超过100GB。
这个大JSON大概格式是这样的:
1 |
[{"question": "xxx111", "answer": "aaa", "crawled_time": "2025-05-01 12:13:14"}, {"question": "xxx222", "answer": "aaa", "crawled_time": "2025-05-01 12:13:14"}, {"question": "xxx333", "answer": "aaa", "crawled_time": "2025-05-01 12:13:14"}, ...] |
要解决这个问题,有三种方法。
如果这个JSON里面没有嵌套数据,只有一层key: value
。那么非常简单。一个字符,一个字符读取。遇到}
的时候,说明一条子JSON数据已经读取完成,解析以后再读取下一条子JSON。
如果这个JSON里面有嵌套结构,那么可以使用经典算法题里面的数括号算法来解决。当发现}
的数量等于{
的时候,说明一个子JSON已经读取完成,可以解析了。
今天我们来介绍第三种方法,使用一个第三方库,叫做ijson
。它天然支持解析这种超大的JSON,并且代码非常简单:
1 |
import ijson |
运行效果如下图所示:
既不会占用大量内存,又能正常解析超大JSON。
2025-03-16 07:44:29
今年315晚会曝光了几个获客软件,号称可以拦截任何人的网络浏览记录,并根据对方在直播软件的留言、打过的电话、浏览过的网址,获取对方的手机号和微信号。还有在地图上随便画一个圈,就能找到圈里面130万人的联系方式。
作为一个软件工程师,我来说说我对他们背后原理的猜测。
晚会里面笼统的说到他们使用了爬虫技术。其实这种说法并不准确。爬虫做不到这种程度。爬虫只能爬取到人眼能看到的各种公开数据。例如有人在直播软件下面回复了评论,爬虫能爬到评论人的用户昵称、评论的内容。但是因为评论人的真名、手机号码和微信号并没有显示在直播软件上,所以爬虫是不能爬到的。它后续还需要使用撞库、社工库、社会工程学等等一系列操作,才能定位到用户的手机号。
以它直播软件获客这个例子,我觉得它背后的原理是这样的:
再说说它在地图上随便画一个圈,就能找到联系方式这个能力。我怀疑它是使用了WIFI探针加上商场的WIFI。
如果我今天刚刚买了一个新的手机卡,把它插在手机上,我不太相信他们能够随便画一个圈,就把我的新手机号获取到了。肯定有一个地方会泄露手机号。那么泄露途径可能有如下几个:
再说一说根据网站访问记录获取手机号。这个我只能说是运营商信息泄露了。2017年,我在北京某公司工作的时候,就拿到过这种运营商数据。不过当时这种数据是脱敏过的。用户信息是md5值,只能根据不同的md5值判断这些请求是不同人的设备发送的,但无法知道具体是谁。这种情况是合法的,本来就有这种公开运营商数据买卖。市面上很多做尽职调查的公司都会采购。提供这种运营商数据的公司,他们会在运营商的机房里面安装记录设备,记录详细信息,然后经过脱敏以后卖给下游公司。
但说不定他们自己也会把没有脱敏的数据经过特殊渠道卖出去,于是就有了今年晚会上的这种功能。
有同学可能会担心这种运营商数据,是不是会把自己访问的每一个URL都记录下来?其实大可不必担心,我们要相信HTTPS。对于使用了HTTPS的网站,运营商那边拿到的数据只能定位到你访问的域名,但无法知道具体的网址。例如你访问了https://xxx.com/aa/bb/cc,运营商记录只能拿到https://xxx.com。无法拿到后面的具体地址。除非他们在你的手机上安装了根证书。所以不要安装来历不明的证书,是保证数据安全的重要前提。
实际上不仅是运营商数据会被出售,银行卡、信用卡、POS机数据也会被出售。有一些做尽职调查的公司,如果要调查某教育机构的学生报名情况,他们会从刷卡数据中筛选出支付给这个教育机构的费用,这样就能算出机构的课程报名情况了。
从上面的分析可以看出,其实要获取一个人的个人信息,爬虫在里面发挥的作用其实是最无足轻重的。随便一个数据的泄露,产生的影响远远超过爬虫。
以上技术方法都是我个人的猜测。都是基于著名的直播软件不可能主动买用户手机号这个前提来做的猜测。
2025-03-14 05:41:41
这两天我使用Cursor开发了一个新闻网站的前端+后端。在开发的过程中,我总结了一些适合于我自己的最佳实践。这些方法让我在使用Cursor的时候,几乎没有遇到任何阻碍,非常顺利,非常流畅地完成了网站的开发。
我的开发经验,总结起来一句话就能说清楚:多写文档少聊天。下面我来详细说一下具体方法。
我全程使用Cursor的agent模式,模型使用Claude 3.7 Sonnet。这个项目是一个新闻网站,需要写前端+后端。
前端我首先使用Trickle生成了页面。大家也可以使用Bolt.new或者lovable,效果都差不多。需要和后端交互的地方都先使用假数据模拟。生成好以后,把代码下载到本地。
使用Cursor打开下载的前端代码,让它阅读代码,并使用Next.js
+ tailwind css
+ shadcn/ui
改写代码。并特别提醒,新版本的shadcn/ui对应的命令应该是npx shadcn xxx
,让他不要再使用老版本的写法。
改写完成以后,执行npm run dev
预览前端页面,确保改写以后的效果跟你之前的一样。
由于前端页面本来就是你设计的,因此你肯定很清楚这个前端页面在哪些地方需要跟后端做交互。
现在,在代码根目录创建一个markdown文件,例如叫做api_desc.md
,然后在里面描述你的后端API。这里描述不需要写得很细节,关键是要写清楚api的功能,路径,参数和返回示例。如下图所示。
由于新闻列表页和详情页需要从MongoDB里面读取,因此再创建一个article_schema.md
,在里面描述数据在MongoDB中的储存结构。如下图所示:
接下来,在Cursor的聊天窗口中,新开一个后端代码专用的对话,让它创建一个后端服务:
1 |
请使用FastAPI帮我创建一个后端服务。这个服务包含多个API接口,接口的需求请参考api_desc.md。列表页的数据和详情页的数据,需要从MongoDB读取,数据结构参考article_schema.md。后端代码写好以后,需要生成一个api_doc.md文件,里面详细描述API的请求地址、参数和返回值格式。以后每次对后端API做修改,都需要同步更新api_doc.md |
Cursor执行完成以后,不仅生成了后端代码,还生成了API文档,文档如下图所示。
这个API文档可以说是相当的标准。
回到前端的对话历史中,让Cursor对接后端:
1 |
现在,我们要开始对接后端API。后端的接口我已经写到了api_doc.md文件中,请仔细阅读,然后修改前端代码的对应位置,把原来的假数据代码改为请求后端API获得正式数据。 |
Cursor修改以后,第一个前后端联动的版本就已经完成了。正常情况下应该已经可以使用了。如果遇到一些小的报错,你可以通过对话的形式让它修复。
接下来,你可能需要在已有的API上面修改字段,或者新增API接口。这种情况下一般都是先让Cursor修改后端代码。它修改以后,会自动更新api_doc.md
文件。然后再让前端代码基于api_doc.md
适配后端的修改。
如果你的修改是小修改,例如在列表页API中添加一个tags参数,用来根据tags过滤新闻,那么你可以直接通过聊天对话的形式跟Cursor沟通,让它完成。
如果你的修改是一个相当完整独立的新功能,那么你可以新增一个需求文档,例如要做一个新闻订阅页面,这个时候就可以创建subscribe_api.md
文件,在里面描述自己需要的API,从而先生成后端代码和API文档,再创建一个subscribe.md
,在里面描述前端页面的需求,并让Cursor基于这个需求加上API文档,生成前端代码。
在使用Cursor的时候,我更倾向于人机协作开发而不是让你当甩手掌柜。你需要给Cursor提供必要的指导,从而让它顺着你的思路来做开发。你的脑子里面要有这个系统的开发路线和架构,你需要知道系统由哪些部分组成,每个部分需要怎么做。软件开发是系统设计+编码。让Cursor去做编码工作,而不是去做设计工作。
不要相信网上那些完全不懂代码的人纯靠文本描述就做出复杂功能的说辞,要不就是他们嘴里的复杂功能其实是简单功能,要不就是他在吹牛。
你应该多写文档,通过文档来描述你的需求。这样Cursor以后的每次修改都会注意不违背你的需求文档。聊天窗口一般是告诉Cursor应该使用哪个文档来进行开发。尽量不要在聊天窗口里面提需求。
2025-02-25 04:26:24
我买的房子今天交房了。开发商配的门锁是某品牌的智能门锁,它可以使用指纹开锁,也可以使用密码开锁。在使用手机跟门锁配对以后,可以远程在手机上生成临时密码。临时密码只能使用1次,并且在生成的30分钟内有效。这个功能可以方便装修人员进出又不用担心泄露密码。
因为新房子还没有通网,所以门锁肯定是无法连接互联网的。而装修人员给我打电话要临时密码时,我在公司,离家几十公里外,门锁也不可能跟手机通信。
那么问题来了,门锁是怎么验证这个临时密码合法的?
今天我一直在想这个问题,目前有一些思路,但无法确定。所以发出来跟大家一起讨论一下它的实现方法。
已知:
首先第4条非常简单,在门锁里面记录一下已经使用的密码就可以实现密码只能使用1次。所以不需要考虑这个问题了。
另外几个问题,我根据我自己的编程经验做一些推测。
临时密码是一个8位数字,例如8031 1257
。由于手机不需要跟门锁通信,门锁就能够识别这个密码,因此我一开始觉得这个8位数字包含某种校验规则。例如,前4个数字,乘以100以后对26取余数,就是第5、6位数字。前6个数乘以5643然后对97取余数,就是第7、8位数字。这里的四个关键数字100
、26
、5643
、97
,可能是手机在和门锁配对的时候发送给门锁的。
但这里无法解释门锁怎么知道数字什么时候过期。难道8位数字能够包含精确到分钟的时间戳信息?例如现在我写文章的时候,对应的时间戳是1740143928
。这是一个10位数字,我实在想不到如何把一个10位的数字藏在8位数字里面,并且在必要的时候还能还原回原来的10位数字。
因此,我换了一个思路,有没有可能密码锁里面自带一个时钟?在手机配对时,会同步校准这个时钟,使它跟手机保持相同的时间。如果是这种方案,那么这个临时密码8位数字,其实可以不用包含自我校验。我们可以使用app和密码锁各自按相同的逻辑走一轮加密,然后对比生成的密钥是否相同。
手机在跟密码锁配对时,发送一个密钥到密码锁里面。要生成临时密码时,手机使用时间戳和密钥通过某个算法生成8个数字。密码锁也使用时间戳和这个相同的密钥,相同的算法,也生成8位数字,如果跟临时密码相同,就开锁。对应的Python密码类似如下:
1 |
import hashlib |
运行效果如下图所示:
由于从任何时间戳x开始,x // 1800 == (x + n) // 1800
或x // 1800 + 1 == (x + n) // 1800
,其中0 <= n <= 1800
。密码锁使用相同的代码,只不过分别把timespan
和timespan + 1
都生成一个密码,然后对比,这样就可以实现手机生成密码过几分钟再在锁上面输入密码,锁也能成功验证。
使用这种方式,可以满足编号1-6
的需求。但问题是遇到需求7
怎么办?上面这种方式,会导致在30分钟内,临时密码只有这一个。
我绞尽脑汁想不出一个聪明的算法能解决这个问题。但是我在多次尝试生成临时密码时,发现两次密码的生成间隔必须大于5秒。
如果它的算法真的那么聪明,为什么不能让我生成无限个可用的临时密码?所以我怀疑它可能没有那么聪明,它可能用的是笨办法,例如:穷举。
对手机来说,生成密码的算法跟上面差不多,唯一的区别是,把其中的1800
换成5
。也就是说,每5秒钟会生成不同的临时密码。因为now // 5
在5秒钟内是相同的,超过5秒就会变。
而对于门锁,当它感知到用户正在输入密码时,以当前时间戳为起点,每5秒一轮,往前推360轮,生成360个密码。如果发现用户输入的8位数跟这360个密码中的某一个相同,就解锁。
如果锁的芯片性能好,在用户按完临时密码前,就能够做完360次计算,用户完全不会有任何感知。如果锁的芯片不够好,它可以在内存中维持一个长度为360的双向链表,每5秒计算一个密码放进去,然后把末尾的密码丢掉。这样当用户输入密码的时候,它可以直接跟这个双向链表中的密码逐一比对。
当然,以上全都是我个人的推测。如果大家知道它是怎么做的,或者想到了什么更好的方法,欢迎留言一起交流。