logseq 迁移思源的 py 脚本丐版

几年前用思源,但是思源无序列表在移动端真的好丑,就继续用 logseq。

两三个月前,用 ai 搞了下 css,把无序列表的显示效果和 logseq 对齐。

[css] 移动端列表紧凑显示,仿 logseq

又在论坛发现了手机端快速分享的 http 快捷方式,替代了 logseq 的 quick capture

安卓版 quickAdd V3,从任意位置向思源笔记发送文字与文件

就写了个 py 脚本把 logseq 数据转思源了

说明:

本脚本只完成了 双链[[]] #标签 日记形如 2025_09_08.md 迁移至思源

思源通过 Markdown.zip 导入 logseq 数据,并不能创建双链页面。思源默认创建页面的位置是在日记页的下级创建,但这样写代码逻辑太码烦,我也不喜欢这样,所以结构和 logseq 保持一致

  • 文件夹名
    • pages
    • daily note

同时 logseq 的日记名,必须像 2025_09_08.md 这样,不是的让 ai 改下 py 脚本

具体变量看脚本内的说明

import os
import re
import time
import random
import string
import datetime
import httpx
from concurrent.futures import ThreadPoolExecutor, wait


"""
本脚本只完成了 双链[[]] #标签 日记形如2025_09_08.md 迁移至思源



设置-文档树
新建文档存放位置 填 /pages
块引新建文档存放位置 填 /pages

然后在daily note下随便一个页面中创建一个新页面,会在创建一个与daily note同级的pages,然后可以把pages下新建的页面删除
也可以把daily note下年份等都删除,此时文件夹结构为

- 文件夹名
  - pages
  - daily note

NOTEBOOK_ID
文件夹名 点击··· 设置 复制id 

PAGES_PARENT_ID
pages 点击··· 复制 复制id

DAILY_PARENT_ID
daily note 点击··· 复制 复制id

AUTH_TOKEN
设置 关于 API token 复制

MARKDOWN_DIR
为logseq文件夹 建议单独把 journals 和 pages 这两文件夹复制出来
"""

# ---------------------- 全局配置 ----------------------
API_URL = "http://127.0.0.1:6806/api/filetree/createDocWithMd"
API_APPEND = "http://127.0.0.1:6806/api/block/appendBlock"
API_SETATTRS = "http://127.0.0.1:6806/api/attr/setBlockAttrs"
AUTH_TOKEN = "usiv1cwdpsq2spw4"
NOTEBOOK_ID = "20250909015054-7hbszfv"
DAILY_PARENT_ID = "20250909015058-5boadzw"
PAGES_PARENT_ID = "20250909015122-xxm3oa7"
MARKDOWN_DIR    = "./lsq"



CREATED_PATHS = set()

import re

def extract_logseq_links_and_tags(text):
    # 1. 剔除所有需要忽略的整块
    text = re.sub(r'#\+BEGIN_QUERY.*?#\+END_QUERY', '', text, flags=re.DOTALL)
    text = re.sub(r'```.*?```', '', text, flags=re.DOTALL)
    text = re.sub(r'`.*?`', '', text, flags=re.DOTALL)
    text = re.sub(r'!?$$.*?$$', '', text, flags=re.DOTALL)
    text = re.sub(r'https?://[^\s<>\]\)]+', '', text, flags=re.DOTALL)

    # 2. 剔除 Markdown 链接 [label](url) 里的 label 部分
    text = re.sub(r'(?<!\!)\[([^\[\]]*)\]\([^\)]*\)', '', text)

    # 3. 提取 [[...]]
    links = re.findall(r'\[\[(.*?)\]\]', text)

    # 4. 提取 #标签,排除 ##,并确保不在代码或链接内
    tags = re.findall(r'(?:^|(?<=\s))#([^\s#]+)', text)

    return links, tags


# -------------------- 时间处理 ------------------------
E8 = datetime.timezone(datetime.timedelta(hours=8))

def parse_date_from_filename(fname):
    """返回 datetime 或 None"""
    m = re.fullmatch(r'(\d{4})_(\d{2})_(\d{2})\.md', fname)
    if m:
        return datetime.datetime(int(m.group(1)), int(m.group(2)), int(m.group(3)), tzinfo=E8)
    return None

def datetime_to_14(dt):
    """20250907171159"""
    return dt.strftime('%Y%m%d%H%M%S')

def file_creation_dt(path):
    """返回文件创建时间 datetime(东八区)"""
    stat = os.stat(path)
    # Android/Unix 创建时间 = st_mtime;如需要 st_birthtime 可改
    t = stat.st_mtime
    return datetime.datetime.fromtimestamp(t, tz=E8)

# -------------------- API 工具 ------------------------
def random_id(dt_prefix=None):
    """14位时间 + 7位随机"""
    prefix = dt_prefix or datetime.datetime.now(tz=E8).strftime('%Y%m%d%H%M%S')
    suffix = ''.join(random.choices(string.ascii_lowercase + string.digits, k=7))
    return f'{prefix}-{suffix}'


def setBlockAttrs(parent_id,date):
    payload = {
        "id": parent_id,
        "attrs": {f"custom-dailynote-{date}":date}
    }
    try:
        #print(parent_id,date,payload)
        resp = httpx.post(API_SETATTRS, json=payload,headers={"Authorization": f"token {AUTH_TOKEN}"},
    timeout=30)
        resp.raise_for_status()
    except Exception as e:
        print('[API_APPEND_ERR]', e, payload)
  


def appendblock(markdown, parent_id, path, id_=None):
    """真正调用思源 API"""
    payload = {
        "parentID": parent_id,
        "dataType": "markdown",
        "data": markdown,
    }
    try:
        #print(path)
        resp = httpx.post(API_APPEND, json=payload,headers={"Authorization": f"token {AUTH_TOKEN}"},
    timeout=30)
        resp.raise_for_status()
    except Exception as e:
        print('[API_APPEND_ERR]', e, payload)
    return resp.json()
  
def get_id_by_hpath(hpath: str) -> str | None:
    """
    通过“笔记本相对路径”/pages/<标签> 查询对应文档 id
    若不存在则返回 None
    """
    url = "http://127.0.0.1:6806/api/filetree/getIDsByHPath"
    try:
        r = httpx.post(
            url,
            json={"notebook": NOTEBOOK_ID, "path": hpath},headers={"Authorization": f"token {AUTH_TOKEN}"},
    timeout=30
        )
        r.raise_for_status()
        data = r.json()
        if data.get("code") == 0 and data.get("data"):
            return data["data"][0]
    except Exception as e:
        print("[get_id_by_hpath error]", e, hpath)
    return None
  


def create_doc(markdown, parent_id, path, id_=None):
    """真正调用思源 API:如 path 已存在则 appendBlock;否则创建。"""
    # 已缓存
    if path in CREATED_PATHS:
        return get_id_by_hpath(f"/{path}")

    # 先查询
    did = get_id_by_hpath(f"/{path}")
    if did:
        CREATED_PATHS.add(path)
        # 非空内容才追加
        print("appendblock 空 ",path,id_)
        if markdown and markdown.strip():
            appendblock(markdown, did, path)
            print("appendblock",path,id_)
        return did

    # 不存在则创建
    id_ = id_ or random_id()
    payload = {
        "notebook": NOTEBOOK_ID,
        "parentID": parent_id,
        "path": path,
        "markdown": markdown,
        "id": id_
    }
    try:
        resp = httpx.post(API_URL,
                          json=payload,
                          headers={"Authorization": f"token {AUTH_TOKEN}"},
                          timeout=30)
        resp.raise_for_status()
        #print("createdoc",path,id_)
        CREATED_PATHS.add(path)
    except Exception as e:
        print('[API_ERR]', e, payload)
    return id_


  
# ---------------- 目录扫描 & 数据收集 ----------------
def scan_markdown_dir():
    """
    返回 (name2ts, daily2file, tag2file)
        name2ts   : 字符串(标签/双链原文) -> 最早出现时间戳
        daily2file: datetime.date -> 文件绝对路径
        tag2file  : 字符串(标签/双链原文) -> 本地同名 .md 绝对路径 或 None
    """
    from collections import defaultdict
    import datetime as dt

    name2ts   = defaultdict(lambda: float('inf'))
    daily2file = {}
    tag2file   = {}          # 仅记录非 daily 文件

    # 先把 daily 文件放进集合,方便后面排除
    daily_paths = set()

    for root, _, files in os.walk(MARKDOWN_DIR):
        for fname in files:
            if not fname.endswith('.md'):
                continue
            full_path = os.path.join(root, fname)

            # 判断是否为 daily
            date_obj = parse_date_from_filename(fname)
            if date_obj:
                ts = int(date_obj.timestamp())
                daily2file[date_obj] = full_path
                daily_paths.add(full_path)
            else:
                ts = int(file_creation_dt(full_path).timestamp())

            with open(full_path, encoding='utf-8') as f:
                content = f.read()

            links, tags = extract_logseq_links_and_tags(content)

            # 合并并记录最早时间戳
            for raw in links + tags:
                name2ts[raw] = min(ts, name2ts[raw])

            # 记录非 daily 文件
            if full_path not in daily_paths:
                name_without_ext = fname[:-3]        # 去掉 .md
                tag2file[name_without_ext] = full_path

    name2ts = {k: v for k, v in name2ts.items() if v != float('inf')}
  
    # 1. 对 name2ts 排序:最旧(时间戳最小)的在最前
    sorted_name2ts = dict(sorted(name2ts.items(), key=lambda kv: kv[1]))

# 2. 对 daily2file 排序:最旧(日期最早)的在最前
    sorted_daily2file = dict(sorted(daily2file.items(), key=lambda kv: kv[0]))

    #return name2ts, daily2file, tag2file
    return sorted_name2ts, sorted_daily2file, tag2file




# ---------------- markdown 处理 ----------------------
# 只用下面这段替换现有的 md_replace_tags 函数
# ---------------- markdown 处理 ----------------------

import re

import os
import re
from typing import List, Tuple, Optional


def md_replace_tags(text: str, file_path: str = "") -> str:
    """
    将 [[标签名]] 与 #标签名 替换为思源块引用;
    若找不到对应文档,则保留 [[标签名]] 形式并打印提示。
    代码 / 查询 / url / 公式 / 链接等区域自动跳过。
    """

    # 1. 需要整块跳过的正则列表
    skip_patterns = [
        r'#\+BEGIN_QUERY[\s\S]*?#\+END_QUERY',
        r'```[\s\S]*?```',
        r'`[^`]*`',
        r'(?<!\!)\$\$[\s\S]*?\$\$(?!\$)',
        r'(?<!\!)\$[^$\n]+(?<!\$)\$(?!\$)',
        r'https?://[^\s<>\]\)]+',
        r'(?<!\!)\[([^\[\]]*)\]\([^\)]*\)',
    ]

    # 2. 收集所有需要跳过的区间
    spans: List[Tuple[int, int]] = []
    for pat in skip_patterns:
        spans.extend((m.start(), m.end()) for m in re.finditer(pat, text))
    spans.sort(key=lambda t: t[0])

    # 合并重叠 / 相邻区间
    merged: List[Tuple[int, int]] = []
    for s, e in spans:
        if merged and s <= merged[-1][1]:
            merged[-1] = (merged[-1][0], max(merged[-1][1], e))
        else:
            merged.append((s, e))

    # 3. 用占位符替换跳过区域,保持长度不变
    Placeholder = "\ue000"
    cleaned_parts: List[str] = []
    last = 0
    for s, e in merged:
        cleaned_parts.append(text[last:s])
        cleaned_parts.append(Placeholder * (e - s))
        last = e
    cleaned_parts.append(text[last:])
    cleaned = "".join(cleaned_parts)

    # 4. 定义替换生成函数
    TagSpan = Tuple[int, int, str]

    def make_replacement(tag: str, m: re.Match[str]) -> Optional[TagSpan]:
        did = get_id_by_hpath(f"/pages/{tag}")
        if not did:
            # 创建空文档
            path = f"pages/{tag}"
            did = create_doc("", PAGES_PARENT_ID, path)
        html = f'<span data-type="block-ref" data-subtype="d" data-id="{did}">{tag}</span>'
        return (m.start(), m.end(), html)


    # 5. 收集 [[...]] 与 #标签
    replacements: List[TagSpan] = []
    for m in re.finditer(r"\[\[([^\[\]\n]+)\]\]", cleaned):
        repl = make_replacement(m.group(1), m)
        if repl:
            replacements.append(repl)

    for m in re.finditer(r"(?:^|(?<=\s))#([^\s#]+)", cleaned):
        repl = make_replacement(m.group(1), m)
        if repl:
            replacements.append(repl)

    # 6. 从后向前替换,避免偏移
    for start, end, repl in sorted(replacements, key=lambda x: x[0], reverse=True):
        text = text[:start] + repl + text[end:]

    return text




# -------------------- 并发任务 ------------------------
def create_tag_pages(name2ts, tag2file):
    """
    根据 name2ts 创建页面。
    页面内容来源:
        - 若 tag2file 中存在同名 .md,则用该文件内容
        - 否则为空
    """
    #futures = []
    #with ThreadPoolExecutor(max_workers=1) as pool:
    for name, ts in name2ts.items():
            path = f'/pages/{name}'

            # 读取内容
            file_path = tag2file.get(name)
            md = ''
            if file_path and os.path.exists(file_path):
                with open(file_path, encoding='utf-8') as f:
                    md = md_replace_tags(f.read(),file_path)

            id_prefix = datetime.datetime.fromtimestamp(ts, tz=E8).strftime('%Y%m%d%H%M%S')
            #f = pool.submit(create_doc, md, PAGES_PARENT_ID, path, random_id(id_prefix))
            #print(path,id_prefix)
            create_doc(md, PAGES_PARENT_ID, path, random_id(id_prefix))
            #futures.append(f)
    #wait(futures)




def create_daily_notes(daily2file):
    """再创建日记"""
    #futures = []
    #with ThreadPoolExecutor(max_workers=1) as pool:
    for date_obj, file_path in daily2file.items():
            with open(file_path, encoding='utf-8') as f:
                md = md_replace_tags(f.read(),file_path)
            path = f'/daily note/{date_obj.year}/{date_obj.month:02d}/{date_obj.strftime("%Y-%m-%d")}'
            id_prefix = datetime_to_14(date_obj)
            #f = pool.submit(create_doc, md, DAILY_PARENT_ID, path, random_id(id_prefix))
            did = create_doc(md, PAGES_PARENT_ID, path, random_id(id_prefix))
            if not did:
                did = get_id_by_hpath(path)
            else:
                #print(path,did)
                setBlockAttrs(did,str(id_prefix[:8]))
            #futures.append(f)
    #wait(futures)



# ------------------------ 主流程 ----------------------
def main():
    name2ts, daily2file, tag2file = scan_markdown_dir()
    print(f'唯一标签/双链 {len(name2ts)} 个,日记 {len(daily2file)} 篇,本地同名文件 {len([v for v in tag2file.values() if v])} 篇。')
  
    create_tag_pages(name2ts, tag2file)
    create_daily_notes(daily2file)
    print('迁移完成。')



if __name__ == '__main__':
    main()



  • 思源笔记

    思源笔记是一款隐私优先的个人知识管理系统,支持完全离线使用,同时也支持端到端加密同步。

    融合块、大纲和双向链接,重构你的思维。

    28444 引用 • 119764 回帖
  • Logseq

    Logseq 是一个隐私优先、开源的知识库工具。

    Logseq is a joyful, open-source outliner that works on top of local plain-text Markdown and Org-mode files. Use it to write, organize and share your thoughts, keep your to-do list, and build your own digital garden.

    8 引用 • 69 回帖 • 6 关注

相关帖子

欢迎来到这里!

我们正在构建一个小众社区,大家在这里相互信任,以平等 • 自由 • 奔放的价值观进行分享交流。最终,希望大家能够找到与自己志同道合的伙伴,共同成长。

注册 关于
请输入回帖内容 ...

推荐标签 标签

  • Facebook

    Facebook 是一个联系朋友的社交工具。大家可以通过它和朋友、同事、同学以及周围的人保持互动交流,分享无限上传的图片,发布链接和视频,更可以增进对朋友的了解。

    4 引用 • 15 回帖 • 444 关注
  • gRpc
    11 引用 • 9 回帖 • 116 关注
  • Laravel

    Laravel 是一套简洁、优雅的 PHP Web 开发框架。它采用 MVC 设计,是一款崇尚开发效率的全栈框架。

    19 引用 • 23 回帖 • 770 关注
  • WiFiDog

    WiFiDog 是一套开源的无线热点认证管理工具,主要功能包括:位置相关的内容递送;用户认证和授权;集中式网络监控。

    1 引用 • 7 回帖 • 633 关注
  • jsoup

    jsoup 是一款 Java 的 HTML 解析器,可直接解析某个 URL 地址、HTML 文本内容。它提供了一套非常省力的 API,可通过 DOM,CSS 以及类似于 jQuery 的操作方法来取出和操作数据。

    6 引用 • 1 回帖 • 517 关注
  • OpenCV
    15 引用 • 36 回帖 • 1 关注
  • Kubernetes

    Kubernetes 是 Google 开源的一个容器编排引擎,它支持自动化部署、大规模可伸缩、应用容器化管理。

    119 引用 • 54 回帖
  • OpenResty

    OpenResty 是一个基于 NGINX 与 Lua 的高性能 Web 平台,其内部集成了大量精良的 Lua 库、第三方模块以及大多数的依赖项。用于方便地搭建能够处理超高并发、扩展性极高的动态 Web 应用、Web 服务和动态网关。

    17 引用 • 51 关注
  • 旅游

    希望你我能在旅途中找到人生的下一站。

    105 引用 • 908 回帖 • 1 关注
  • RYMCU

    RYMCU 致力于打造一个即严谨又活泼、专业又不失有趣,为数百万人服务的开源嵌入式知识学习交流平台。

    4 引用 • 6 回帖 • 56 关注
  • Google

    Google(Google Inc.,NASDAQ:GOOG)是一家美国上市公司(公有股份公司),于 1998 年 9 月 7 日以私有股份公司的形式创立,设计并管理一个互联网搜索引擎。Google 公司的总部称作“Googleplex”,它位于加利福尼亚山景城。Google 目前被公认为是全球规模最大的搜索引擎,它提供了简单易用的免费服务。不作恶(Don't be evil)是谷歌公司的一项非正式的公司口号。

    51 引用 • 200 回帖 • 2 关注
  • 运维

    互联网运维工作,以服务为中心,以稳定、安全、高效为三个基本点,确保公司的互联网业务能够 7×24 小时为用户提供高质量的服务。

    151 引用 • 257 回帖 • 1 关注
  • Openfire

    Openfire 是开源的、基于可拓展通讯和表示协议 (XMPP)、采用 Java 编程语言开发的实时协作服务器。Openfire 的效率很高,单台服务器可支持上万并发用户。

    6 引用 • 7 回帖 • 133 关注
  • 强迫症

    强迫症(OCD)属于焦虑障碍的一种类型,是一组以强迫思维和强迫行为为主要临床表现的神经精神疾病,其特点为有意识的强迫和反强迫并存,一些毫无意义、甚至违背自己意愿的想法或冲动反反复复侵入患者的日常生活。

    15 引用 • 161 回帖 • 1 关注
  • 30Seconds

    📙 前端知识精选集,包含 HTML、CSS、JavaScript、React、Node、安全等方面,每天仅需 30 秒。

    • 精选常见面试题,帮助您准备下一次面试
    • 精选常见交互,帮助您拥有简洁酷炫的站点
    • 精选有用的 React 片段,帮助你获取最佳实践
    • 精选常见代码集,帮助您提高打码效率
    • 整理前端界的最新资讯,邀您一同探索新世界
    488 引用 • 384 回帖
  • Ruby

    Ruby 是一种开源的面向对象程序设计的服务器端脚本语言,在 20 世纪 90 年代中期由日本的松本行弘(まつもとゆきひろ/Yukihiro Matsumoto)设计并开发。在 Ruby 社区,松本也被称为马茨(Matz)。

    7 引用 • 31 回帖 • 299 关注
  • CAP

    CAP 指的是在一个分布式系统中, Consistency(一致性)、 Availability(可用性)、Partition tolerance(分区容错性),三者不可兼得。

    12 引用 • 5 回帖 • 660 关注
  • Postman

    Postman 是一款简单好用的 HTTP API 调试工具。

    4 引用 • 3 回帖
  • Ngui

    Ngui 是一个 GUI 的排版显示引擎和跨平台的 GUI 应用程序开发框架,基于
    Node.js / OpenGL。目标是在此基础上开发 GUI 应用程序可拥有开发 WEB 应用般简单与速度同时兼顾 Native 应用程序的性能与体验。

    7 引用 • 9 回帖 • 430 关注
  • WebClipper

    Web Clipper 是一款浏览器剪藏扩展,它可以帮助你把网页内容剪藏到本地。

    3 引用 • 9 回帖 • 3 关注
  • 机器学习

    机器学习(Machine Learning)是一门多领域交叉学科,涉及概率论、统计学、逼近论、凸分析、算法复杂度理论等多门学科。专门研究计算机怎样模拟或实现人类的学习行为,以获取新的知识或技能,重新组织已有的知识结构使之不断改善自身的性能。

    78 引用 • 37 回帖
  • ngrok

    ngrok 是一个反向代理,通过在公共的端点和本地运行的 Web 服务器之间建立一个安全的通道。

    7 引用 • 63 回帖 • 668 关注
  • 负能量

    上帝为你关上了一扇门,然后就去睡觉了....努力不一定能成功,但不努力一定很轻松 (° ー °〃)

    89 引用 • 1251 回帖 • 376 关注
  • 学习

    “梦想从学习开始,事业从实践起步” —— 习近平

    176 引用 • 544 回帖
  • 工具

    子曰:“工欲善其事,必先利其器。”

    308 引用 • 773 回帖
  • 小薇

    小薇是一个用 Java 写的 QQ 聊天机器人 Web 服务,可以用于社群互动。

    由于 Smart QQ 从 2019 年 1 月 1 日起停止服务,所以该项目也已经停止维护了!

    35 引用 • 468 回帖 • 768 关注
  • Vditor

    Vditor 是一款浏览器端的 Markdown 编辑器,支持所见即所得、即时渲染(类似 Typora)和分屏预览模式。它使用 TypeScript 实现,支持原生 JavaScript、Vue、React 和 Angular。

    386 引用 • 1892 回帖 • 1 关注