简介:这份《钉钉使用手册(试运行)》面向企业行政、人资及中高层管理者,用于规范内部沟通与数字化办公流程。手册围绕钉钉软件使用原则、考勤打卡、审批流程、日报功能及使用注意事项展开,明确了中高层需在电脑端安装PC端、紧急事务仍以电话沟通、签到打卡与审批日志等功能的定位,并对人脸识别打卡、300米范围限制、外出与请假审批权限、费用审批归属、日报填报字段及未提报的处理办法作出具体说明,同时强调实名账号、信息及时回复与DING提醒机制。资源共1个PDF文件,压缩包约115KB,体量轻便,适合直接打印或作为内部制度附件下发传阅。目前已有410人浏览学习。读者可据此快速建立企业钉钉使用的统一标准,明确各角色职责边界与审批路径,减少沟通与考勤管理中的争议,并作为制度试运行阶段的参考蓝本。
1. 一份"试运行"的PDF手册,最难的不是写,而是让它被真的查得到
群里甩一份《钉钉使用手册(试运行).pdf》,两周后就没人点开了。新人问"审批流怎么加会签",答案就在第 37 页,可钉钉群聊的搜索框搜不到 PDF 里任何一个字。PDF 是给眼睛看的版式,不是给检索用的数据结构,这是它落地失败的第一个原因。
真正要做的三件事很具体:把 PDF 解析成带页码的章节块,落一份本地索引,再挂一个钉钉机器人当入口,谁问就在群里回页码和原文片段。不依赖大模型也能先跑起来,靠 SQLite 全文检索就能把"翻 37 页"变成"3 秒出答案"。这套做法适合维护内部工具文档的 IT 支持、运维和行政管理员,文档量从几十页到几百页都吃得下。
2. 解析《钉钉使用手册(试运行).pdf》:拆出能检索的章节块
2.1 先判断这份 PDF 是文本型、扫描型还是混合型
解析策略全看这一步。用 poppler 自带的两条命令,五秒钟就能定性,避免写完 OCR 才发现根本不需要。
# 看字体嵌入情况:有正常字体名 = 文本型;输出为空或只有 Type3 = 扫描/图片型 pdffonts "钉钉使用手册(试运行).pdf" | head -20 # 直接抽取前 3 页正文,看有没有可读中文 pdftotext -f 1 -l 3 -layout "钉钉使用手册(试运行).pdf" - | head -60pdffonts的emb列为yes且字体名正常,说明文字是矢量文本,走 PyMuPDF 直接抽;如果输出为空,或者pdftotext出来的是一片空白、乱码、只有零星几个字,那就是扫描件或截图排版,需要先做 OCR 再进后面的流程。
| 文档类型 | pdffonts 特征 | 抽取手段 | 主要风险 |
|---|---|---|---|
| 文本型 | 有多个嵌入字体 | PyMuPDFget_text("dict") | 页眉页脚混入正文 |
| 扫描型 | 空输出 / Type3 | OCR 后按行重组 | 文字顺序错乱、表格丢列 |
| 混合型 | 正文有字体,插图为截图 | 文本抽取 + 图片区域 OCR | 同一章节两种来源,页码对不齐 |
2.2 用 PyMuPDF 抽取文本块与目录树的最小脚本
文本型文档不需要 OCR,核心是把"页"拆成"块",因为后面切块、定位页码都要靠块级坐标。
import fitz, re, json HEADER_ZONE = 0.08 # 页面上 8% 高度视为页眉区,下 8% 视为页脚区 doc = fitz.open("钉钉使用手册(试运行).pdf") print("页数:", doc.page_count) print("目录项:", doc.get_toc()[:6]) # [[层级, 标题, 页码], ...],没有书签时为空列表 blocks = [] for pno, page in enumerate(doc): h = page.rect.height for x0, y0, x1, y1, txt, _no, btype in page.get_text("blocks"): if btype != 0: # 1 表示图片块,这里先跳过 continue if y0 < h * HEADER_ZONE or y1 > h * (1 - HEADER_ZONE): continue # 丢掉页眉页脚和页码行 txt = re.sub(r"[ \t\u3000]+", " ", txt).strip() if len(txt) < 2: continue blocks.append({"page": pno + 1, "y": round(y0, 1), "text": txt}) json.dump(blocks, open("blocks.json", "w"), ensure_ascii=False, indent=1) print("有效文本块:", len(blocks))get_text("blocks")返回的是(x0, y0, x1, y1, 文本, 块序号, 块类型)七元组,btype为 0 是文字、为 1 是图片。HEADER_ZONE是关键参数:手册类文档页眉通常是"钉钉使用手册(试运行)"加页码,落到索引里会污染检索结果,按比例裁掉最省事。如果文档页眉特别矮,把 0.08 调到 0.05;如果正文本身就有高于 8% 的顶部留白导致正文被误删,就改成按"文字是否重复出现在 70% 以上页面"来判定页眉。
doc.get_toc()读的是 PDF 书签。有书签的文档可以直接用它做章节边界,比字号猜标题准得多。
2.3 还原标题层级的三个判据:字号、加粗、编号正则
没有书签时只能从字形反推标题。做法是先统计全文字号分布,出现字符数最多的那个字号就是正文字号,比它大 1.5pt 以上的基本是标题。
HEAD_RE = re.compile(r"^(第[一二三四五六七八九十]+[章节]|\d+(\.\d+){0,3}[、.\s]|附录[A-Z]?)") def scan_styles(path, pages=(0, 1, 2, 5)): doc, stat = fitz.open(path), {} for pno in pages: for blk in doc[pno].get_text("dict")["blocks"]: for line in blk.get("lines", []): for sp in line["spans"]: key = (round(sp["size"], 1), sp["font"]) stat[key] = stat.get(key, 0) + len(sp["text"].strip()) return sorted(stat.items(), key=lambda x: -x[1])[:12] for (size, font), chars in scan_styles("钉钉使用手册(试运行).pdf"): print(f"size={size:>5} font={font:<26} chars={chars}")拿到body_size之后判定标题:
def is_heading(span, body_size): text = span["text"].strip() if not text or len(text) > 40: # 标题一般不超过 40 字 return False if span["size"] >= body_size + 1.5: return True if "Bold" in span["font"] and HEAD_RE.match(text): return True return False三个判据的分工:字号管一级、二级标题;加粗加编号正则管"1.1.1"这类三级标题,因为很多手册排版时三级标题字号和正文一样,只加粗。三个条件都不满足就当正文处理。误判标题比漏判标题危害大——误判会把正文截断成碎片,检索时召回的片段没有上下文。
2.4 切块策略:按标题切、按字数兜底、带重叠
章节块是检索的最小单元,太短则语义不全,太长则噪声大。
| 参数 | 建议值 | 作用 | 调大/调小的后果 |
|---|---|---|---|
max_chars | 600 | 单块字数上限 | 调大召回更全但噪声多 |
overlap | 80 | 相邻块重叠字数 | 防止答案刚好被切断 |
min_chars | 120 | 小于此值丢弃 | 过滤"注意事项:"这类残块 |
def build_chunks(blocks, max_chars=600, overlap=80, min_chars=120): chunks, buf, buf_chars = [], [], 0 cur_title, cur_page = "未命名章节", 1 def flush(): nonlocal buf, buf_chars text = "\n".join(buf).strip() if len(text) >= min_chars: chunks.append({"heading": cur_title, "page": cur_page, "body": text}) buf, buf_chars = [], 0 for b in blocks: if b.get("is_heading"): flush() cur_title, cur_page = b["text"], b["page"] buf.append(b["text"]) buf_chars += len(b["text"]) if buf_chars >= max_chars: flush() if chunks: # 带 overlap 续切,保住跨块句子 tail = chunks[-1]["body"][-overlap:] buf, buf_chars = [tail], len(tail) flush() return chunksflush()负责把缓冲区落成一个块,cur_title记录当前块归属的最近一个上级标题,cur_page记录该标题所在页码——这两个字段是后面在钉钉群里回"《审批管理》第 37 页"的依据。
2.5 解析结果自检:空页率、标题数和抽取覆盖率
别急着建索引,先验一遍。三个数字看一眼就知道解析质量:空页率(有文字页数 / 总页数)低于 0.9 说明大量页面被裁没了;标题数应该和目录能对上,差太多说明字形判据失效;随机抽 5 个块人工读一遍,看有没有页眉混入。
pages_with_text = len({b["page"] for b in blocks}) print("覆盖率: %.2f" % (pages_with_text / doc.page_count)) print("抽到的标题数:", sum(1 for b in blocks if b.get("is_heading"))) print("切块数:", len(chunks), "平均字数:", sum(len(c["body"]) for c in chunks) // len(chunks))3. 建本地索引:SQLite FTS5 加 jieba 分词,把手册变成秒级可查
3.1 为什么内部手册先别急着上向量库和问答大模型
手册类文档的提问大多是关键词型:"会签""抄送人""日志导出在哪",倒排索引在这种场景下的准确率往往比向量检索还高,因为用户就是照着原文的词在问。另一个现实问题是运维成本——一个 SQLite 文件、一份 Python 脚本,拷到内网机器上就能跑,不需要显卡、不需要额外服务。等关键词检索的效果被验证过,再叠加向量召回补"同义不同词"的缺口,这是更稳的顺序。
3.2 建表与写入:FTS5 虚拟表的字段设计
三列可检索、两列只存不索引。body存原文用于展示,body_seg存分词结果用于匹配。
import sqlite3, jieba conn = sqlite3.connect("manual.db") conn.execute("DROP TABLE IF EXISTS manual_fts") conn.execute(""" CREATE VIRTUAL TABLE manual_fts USING fts5( heading, body_seg, body, page UNINDEXED, chunk_id UNINDEXED, tokenize = 'unicode61 remove_diacritics 2' ) """) def seg(text): return " ".join(t.strip() for t in jieba.cut_for_search(text) if t.strip()) conn.executemany( "INSERT INTO manual_fts VALUES (?,?,?,?,?)", [(c["heading"], seg(c["body"]), c["body"], c["page"], i) for i, c in enumerate(chunks)] ) conn.commit() print("入库块数:", conn.execute("SELECT count(*) FROM manual_fts").fetchone()[0])tokenize = 'unicode61'是 SQLite 的内置分词器,它对拉丁文按空格和标点切,但会把连续的中文当成一整个 token,所以中文必须靠jieba预分词写进body_seg。UNINDEXED表示这两列只存不建倒排,页码和块 ID 不需要被搜到,标上能明显减小索引体积。
3.3 中文分词的坑:unicode61、trigram 与 jieba 预分词
| 方案 | 二字符中文查询 | 索引体积 | 适用场景 |
|---|---|---|---|
| unicode61 不分词 | 基本搜不到 | 最小 | 纯英文文档 |
| trigram(SQLite ≥ 3.34) | 长度 < 3 的查询失效 | 较大 | 不想引入分词库 |
| jieba 预分词 + unicode61 | 正常 | 中等 | 中文手册,推荐 |
选 jieba 的实际原因:手册里大量出现"待办""会签""抄送"这类两字词,trigram 要求至少三个字符才能命中,正好踩空。注意cut_for_search会把"审批流程"切成"审批 流程 审批流程",颗粒度更细,召回更全,代价是索引稍大。
3.4 查询构造与 bm25 权重调参
def search(conn, q, topk=8): terms = [t.strip() for t in jieba.cut_for_search(q) if t.strip()] match = " AND ".join(f'"{t}"' for t in terms) # 加引号,避免 "%" 等被当成 FTS5 语法 sql = """ SELECT chunk_id, page, heading, snippet(manual_fts, 1, '[', ']', '…', 16) AS hit, bm25(manual_fts, 4.0, 1.0, 0.0) AS score FROM manual_fts WHERE manual_fts MATCH ? ORDER BY score ASC LIMIT ? """ return conn.execute(sql, (match, topk)).fetchall()bm25(表名, w1, w2, w3)的三个数字分别是heading、body_seg、body三列的权重。标题命中比正文命中更有价值,所以heading给 4.0;body只存不索引,权重写 0.0。bm25()返回的是负数,越小越相关,所以是ORDER BY score ASC。
snippet(表名, 列号, 起始标记, 结束标记, 省略号, 片段词数)里列号按建表顺序从 0 开始,1就是body_seg,返回的片段会被分词后的空格隔开,展示前用replace(" ", "")还原。如果发现搜"会签"总是先出目录页,把heading权重降到 2.0 再试,或者加一条WHERE page > 3过滤前置目录。
3.5 用 RRF 做关键词与向量双路融合
关键词搞不定的场景是"怎么让别人帮我签字"对不上原文的"加签"。补一路向量召回,再用 RRF 融合排名。
def rrf(rank_lists, k=60, topn=8): score = {} for lst in rank_lists: # 每个 lst 是排好序的 chunk_id 列表 for rank, cid in enumerate(lst, start=1): score[cid] = score.get(cid, 0.0) + 1.0 / (k + rank) return sorted(score.items(), key=lambda x: -x[1])[:topn]k=60是 RRF 的标准平滑项,作用是把头部排名的差距拉平,避免某一路的第 1 名压过另一条路的前 10 名。这个融合只吃排名不吃分数,所以 BM25 分值和余弦相似度的量纲差异不会互相干扰——这正是选 RRF 而不是加权求和的原因。
4. 接进钉钉群:机器人加签、卡片消息与失败排查
4.1 自定义机器人的安全设置与权限边界
群里加"自定义机器人"后,安全设置有三选一:自定义关键词、加签、IP 白名单。用机器人做检索入口必须选加签,因为关键词方式要求每条消息都包含指定字符串,而答案内容是动态的,硬塞关键词会污染展示。加签是拿timestamp + "\n" + secret做 HMAC-SHA256,把结果拼到 Webhook URL 上,服务端校验时间戳,超过一小时会拒绝。
4.2 加签 Webhook 的最小可用代码
import time, hmac, hashlib, base64, urllib.parse, requests WEBHOOK = "https://oapi.dingtalk.com/robot/send?access_token=你的token" SECRET = "SEC你的加签密钥" def build_url(): ts = str(round(time.time() * 1000)) digest = hmac.new(SECRET.encode("utf-8"), f"{ts}\n{SECRET}".encode("utf-8"), hashlib.sha256).digest() sign = urllib.parse.quote_plus(base64.b64encode(digest)) return f"{WEBHOOK}×tamp={ts}&sign={sign}" def send_markdown(title, text, at_mobile=None): body = {"msgtype": "markdown", "markdown": {"title": title[:20], "text": text}, "at": {"atMobiles": [at_mobile] if at_mobile else []}} return requests.post(build_url(), json=body, timeout=5).json()三个容易踩的点:timestamp必须是毫秒字符串,用time.time()*1000而不是秒;sign必须 URL 编码,否则+/=会被 URL 解析吃掉导致验签失败;title过长会被截断甚至报参数错误,实践里控制在 20 个字符以内最稳。
4.3 长答案怎么发:markdown 消息、actionCard 与跳转链接
文本和 markdown 消息有体积上限(中文按 UTF-8 三字节算,很容易触顶),一次问答别把三页原文全塞进去。
| 消息形态 | 适合场景 | 关键字段 |
|---|---|---|
| markdown | 一条答案 + 片段引用 | markdown.title/markdown.text |
| actionCard | 需要点击跳转(如跳到禅道工单或内部 Wiki) | singleTitle/singleURL |
| file | 附件分发,走media/upload换media_id | media_id/fileName |
def reply_with_cards(query, hits): lines = [f"**{query}** 命中 {len(hits)} 条:"] for cid, page, heading, hit, _score in hits[:5]: lines.append(f"- 《{heading}》第 {page} 页:{hit.replace(' ', '')}") lines.append("\n> 点下方按钮可直接建工单确认细节") body = { "msgtype": "actionCard", "actionCard": { "title": "钉钉使用手册检索结果", "text": "\n".join(lines), "btnOrientation": "0", "singleTitle": "去禅道提工单", "singleURL": "https://zhanda.example.com/ticket/create?title=" + urllib.parse.quote(query) } } return requests.post(build_url(), json=body, timeout=5).json()actionCard的价值在于把"查文档"和"提单"接成一条链路:机器人给出页码和片段,员工确认不是自己看漏了,一键跳到禅道建单。这比让人在群里追问"到底该找谁"省一轮沟通。
4.4 钉钉群发不了文件、提示钉盘容量不足时先查什么
群文件走的是群所属钉盘空间,空间被占满时发送会直接失败,报"钉盘容量不足"。这类问题在手册附件分发场景里高发,因为动辄几 MB 的 PDF 反复上传。
| 症状 | 常见原因 | 处理方向 |
|---|---|---|
| 提示钉盘容量不足 | 群空间配额用满 | 清理群历史文件,或改为发链接而非附件 |
| 机器人不回复 | 加签失败 / 关键词模式冲突 | 返回体 errcode 310000 时先查时间戳和 sign |
| 短时间大量请求被拒 | 机器人发送频率限制 | 加队列,单群串行发送,失败退避重试 |
| 消息发出但内容被截断 | 消息体超出体积上限 | 拆成多条,或转成 file 消息发送 |
发送失败时把响应体里的errcode和errmsg打进日志——只打status_code会误判,因为钉钉的限流和验签失败往往返回 HTTP 200,错误藏在 JSON 里。
5. 手册改版之后:增量重建、版本 diff 与在线预览
"试运行"三个字意味着手册会经常改。每次改版都全量重建索引,代价是解析、分词、入库全部重跑,几百页文档要几分钟;更糟的是如果中途失败,索引会处于半新半旧状态。
更省事的做法是按页做指纹,只重算变化的页。页面文本的 MD5 对排版微调不敏感、对内容修改敏感,正好合适。
import fitz, hashlib, json def fingerprint(path): doc = fitz.open(path) return {str(p): hashlib.md5(doc[p].get_text("text").encode()).hexdigest() for p in range(doc.page_count)} new_fp = fingerprint("钉钉使用手册(试运行).pdf") old_fp = json.load(open("fp.json")) if os.path.exists("fp.json") else {} changed = [int(p) for p in new_fp if old_fp.get(p) != new_fp[p]] print("变化页:", changed, "新增页:", [int(p) for p in new_fp if p not in old_fp]) json.dump(new_fp, open("fp.json", "w"))拿到changed之后,先DELETE FROM manual_fts WHERE page IN (...)再重新插入这些页对应的块,其他块原样保留。注意一个坑:手册正文增删会导致后面所有页的页码整体偏移,按页号删除会把没改的内容误删。稳妥的判断是页号变化但内容指纹相同的页,只更新它的page字段;内容指纹不同才真正重建。
在线预览这块,常见组合是把手册文件目录挂到 Alist,再起一个 OnlyOffice 容器做文档渲染,群里机器人发的是 Alist 的分享链接而不是 PDF 文件本身——这条路绕开了钉盘容量不足的问题,也保证员工看到的永远是最新版而不是聊天记录里的旧附件。
| 预览方案 | 部署成本 | 保真度 | 适合的文档 |
|---|---|---|---|
| Alist + OnlyOffice | 需要容器和回调地址 | 高,可在线批注 | 频繁改版的手册、表格多的文档 |
| 服务端转 PDF 后给链接 | 低,一条命令 | 中,版式可能轻微移位 | 以文字为主的说明文档 |
| 直接发原文件 | 零成本 | 最高 | 定稿不再变的归档版 |
一个具体技巧:把检索结果的页码直接拼成 Alist 的锚点链接(形如#page=37),员工点开就跳到对应页,省掉在长文档里手动翻。这个改动只需要在 4.3 的拼装逻辑里改一行 URL,对使用体验的提升比调 BM25 权重明显得多。
本文还有配套的精品资源,点击获取