├── .gitignore ├── .idea ├── .gitignore ├── PostSync.iml ├── inspectionProfiles │ ├── Project_Default.xml │ └── profiles_settings.xml ├── misc.xml ├── modules.xml └── vcs.xml ├── .vscode ├── launch.json └── settings.json ├── LICENSE ├── PostSync.spec ├── Readme.md ├── app.py ├── common ├── apis.py ├── constant.py ├── core.py ├── error.py ├── handler.py └── result.py ├── config.yaml ├── docs ├── 使用时的常见问题处理.md ├── 存储路径设置.md ├── 实现流程分析.md ├── 开发时的常见问题处理.md ├── 文章对网站收入各种合集的映射.md └── 设计优缺点.md ├── entity ├── bilibili.py ├── cnblog.py ├── csdn.py ├── juejin.py ├── wechat.py └── zhihu.py ├── make.py ├── make_local.py ├── poetry.lock ├── pyproject.toml ├── server ├── dashboard.py ├── plugins.py ├── post.py ├── setting.py ├── static.py ├── window.py └── write.py ├── static ├── imgs │ ├── default-cover.png │ ├── logo.ico │ ├── logo.png │ ├── official-account.jpg │ └── reward-wechat.jpg └── scripts │ └── stealth.min.js ├── tests ├── assets │ ├── imgs │ │ ├── ad.png │ │ ├── adguard.png │ │ ├── logo.png │ │ └── wp.png │ ├── jsons │ │ └── test.json │ └── posts │ │ ├── PostSync介绍.docx │ │ ├── PostSync介绍.html │ │ ├── PostSync介绍.md │ │ ├── imgs │ │ └── img0.png │ │ └── 利用AdGuard屏蔽必应搜索中的CSDN内容.md ├── pytest.ini ├── test_post.py ├── units │ ├── funcs │ │ ├── test_browser_func.py │ │ ├── test_data_func.py │ │ ├── test_domain_func.py │ │ └── test_file_func.py │ ├── test_browser.py │ ├── test_config.py │ ├── test_dir.py │ ├── test_documents.py │ ├── test_error.py │ ├── test_window.py │ └── ui │ │ ├── test_drag.py │ │ ├── test_file.py │ │ ├── test_menu.py │ │ └── test_storage.py └── whole │ └── test_async.py ├── ui ├── .eslintrc.js ├── .gitignore ├── .npmignore ├── README.md ├── index.html ├── package-lock.json ├── package.json ├── public │ ├── favicon.ico │ └── imgs │ │ └── logo-landscape.png ├── src │ ├── App.vue │ ├── apis │ │ ├── dashboard.ts │ │ ├── plugin.ts │ │ ├── post.ts │ │ ├── setting.ts │ │ ├── window.ts │ │ └── write.ts │ ├── assets │ │ ├── imgs │ │ │ ├── logo-landscape.png │ │ │ └── logo.png │ │ └── style │ │ │ ├── base.css │ │ │ ├── global.css │ │ │ └── theme.css │ ├── components │ │ ├── Editor.vue │ │ ├── InputSetting.vue │ │ ├── PostListItem.vue │ │ └── SiteStatusItem.vue │ ├── directives │ │ └── click-once.directive.ts │ ├── main.ts │ ├── router │ │ └── index.ts │ ├── store │ │ ├── config.ts │ │ └── site.ts │ ├── types │ │ ├── config.d.ts │ │ ├── post.d.ts │ │ └── site.d.ts │ ├── utils │ │ ├── helper.ts │ │ └── request.ts │ ├── views │ │ ├── Config.vue │ │ ├── Dashboard.vue │ │ ├── Layout.vue │ │ ├── Plugins.vue │ │ ├── Test.vue │ │ ├── Upload.vue │ │ └── Write.vue │ └── vite-env.d.ts ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts └── utils ├── analysis.py ├── browser.py ├── data.py ├── device.py ├── domain.py ├── file.py ├── helper.py ├── load.py ├── plugins.py └── storage.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | .idea/ 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | workspace.xml 81 | # SageMath parsed files 82 | *.sage.py 83 | .idea/workspace.xml 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | # Editor-based HTTP Client requests 5 | /httpRequests/ 6 | # Datasource local storage ignored files 7 | /dataSources/ 8 | /dataSources.local.xml 9 | -------------------------------------------------------------------------------- /.idea/PostSync.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 14 | 15 | 17 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/Project_Default.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/profiles_settings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "type": "python", 6 | "request": "launch", 7 | "name": "Launch Flask App", 8 | "program": "${workspaceFolder}/app.py", 9 | "args": [], 10 | "env": { 11 | "FLASK_APP": "app.py", 12 | "FLASK_ENV": "development" 13 | }, 14 | "console": "integratedTerminal" 15 | }, 16 | { 17 | "type": "node", 18 | "request": "launch", 19 | "name": "Launch npm run dev", 20 | "program": "${workspaceFolder}/ui/node_modules/.bin/npm", 21 | "args": ["run", "dev"], 22 | "cwd": "${workspaceFolder}/ui", 23 | "console": "integratedTerminal" 24 | } 25 | ], 26 | "compounds": [ 27 | { 28 | "name": "Launch Flask and npm", 29 | "configurations": ["Launch Flask App", "Launch npm run dev"] 30 | } 31 | ] 32 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.words": [ 3 | "bilibili" 4 | ] 5 | } -------------------------------------------------------------------------------- /PostSync.spec: -------------------------------------------------------------------------------- 1 | # -*- mode: python ; coding: utf-8 -*- 2 | from PyInstaller.building.api import PYZ, EXE, COLLECT 3 | from PyInstaller.building.build_main import Analysis 4 | 5 | a = Analysis( 6 | ['app.py'], 7 | pathex=[ 8 | '.' 9 | ], 10 | binaries=[], 11 | datas=[ 12 | ('config.yaml', '.'), 13 | ('entity', 'entity'), 14 | ('common', 'common'), 15 | ('static', 'static'), 16 | ('utils', 'utils'), 17 | ('ui', 'ui'), 18 | ('server', 'server'), 19 | ], 20 | hiddenimports=['bs4','pyperclip'], # 遇到No module named xxx 等问题,添加依赖库到这里 21 | hookspath=[], 22 | hooksconfig={}, 23 | runtime_hooks=[], 24 | excludes=['tests','docs'], 25 | noarchive=False, 26 | optimize=0, 27 | ) 28 | pyz = PYZ(a.pure) 29 | 30 | exe = EXE( 31 | pyz, 32 | a.scripts, 33 | [], 34 | exclude_binaries=True, 35 | name='PostSync', 36 | debug=False, 37 | bootloader_ignore_signals=False, 38 | strip=False, 39 | upx=True, 40 | console=False, 41 | disable_windowed_traceback=False, 42 | argv_emulation=False, 43 | target_arch=None, 44 | codesign_identity=None, 45 | entitlements_file=None, 46 | icon=['static\\imgs\\logo.ico'], 47 | ) 48 | coll = COLLECT( 49 | exe, 50 | a.binaries, 51 | a.datas, 52 | strip=False, 53 | upx=True, 54 | upx_exclude=[], 55 | name='PostSync', 56 | ) 57 | -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 |
2 | 3 |

4 | PostSync 5 |

6 |

7 | 促进技术文章发展 8 |

9 |
10 | 11 | ### 介绍📖 12 | 13 | PostSync是一款开源的跨平台文章同步工具,可以同步你的文章到多个平台。 14 | 一次编写,多处同步,同时上传标签,分 类,栏目,封面等参数。 15 | 16 | [![](http://i2.hdslb.com/bfs/archive/da393fd721b3b958f88a4cb5d08df3af9528ec57.jpg)](https://www.bilibili.com/video/BV1P3FXejERT) 17 | 18 | ### 使用🎭 19 | 20 | 1. 打开PostSyncGUI.exe文件 21 | 2. 登录相关平台 22 | 3. 配置相关平台的默认参数 23 | 4. 选择需要同步的文章上传 24 | 25 | ### 注意事项 26 | 27 | - 使用标签分类等功能请确保您在相关平台上已经创建相应的标签分类 28 | 29 | ### 开发⛏️ 30 | 31 | #### 配置debug 32 | 33 | 打开`config.yaml`文件,将`app/debug`设置为`True` 34 | 35 | #### 打包 36 | 37 | ``` bash 38 | python make.py 39 | ``` 40 | 41 | > 打包时会自动将`config.yaml`中的相关参数设置为产品环境 42 | 43 | ### 功能📲 44 | 45 | - 自动同步文章到掘金、CSDN、知乎、公众号、哔哩哔哩、博客园等平台并返回生成文章链接 46 | - 支持多协程,异步上传文章 47 | - 支持包含查找,大小写模糊匹配 48 | - 支持md,html,docx文件上传,并实现自动转换 49 | - 支持自定义默认配置 50 | - 自定义标题、标签、分类、专栏、封面、摘要等 51 | - 支持插件扩展 52 | - 撰写markdown文章 53 | 54 | ### 优化任务🕐 55 | 56 | - [ ] 完善开发文档 57 | - [ ] 撰写文章图片显示问题 58 | - [ ] 搭配图床接口 59 | - [ ] 记录失败日志 60 | - [ ] 公众号直接发布 61 | - [ ] 包含查找优化为近似查找 62 | 63 | ### 开发规范📃 64 | 65 | - entity包下的新增社区应继承Community类 66 | - 新增社区类的命令应为首字母大写其余字母全部小写 67 | - 代码风格遵循PEP8规范 68 | 69 | ### 关于作者👨‍💻 70 | 71 | 作者本人目前就读于中原工学院,是一名超级热爱编程的本科生 72 | 喜欢各种运动和各种音乐 73 | 74 | - 邮箱: 75 | - 网站: 76 | 77 | 公众号: 78 | 79 | ![云奕科软公众号二维码](./static/imgs/official-account.jpg?=50x50) 80 | 81 | ### 打赏💴 82 | 83 | 如果觉得本软件对您有帮助,不如请我喝杯☕! 84 | 85 | ![微信支付](./static/imgs/reward-wechat.jpg?=50x50) 86 | 87 | ### 鸣谢🍻 88 | 89 | - 感谢JetBrains公司提供的免费学生许可证 90 | - 感谢FittenCode AI智能代码辅助助手的大力相助 91 | -------------------------------------------------------------------------------- /app.py: -------------------------------------------------------------------------------- 1 | import threading 2 | from flask import Flask 3 | from common import constant as c 4 | import webview 5 | from utils.storage import setup_wizard 6 | from server.dashboard import dashboard_api 7 | from server.write import write_api 8 | 9 | 10 | class WindowApi(): 11 | def __init__(self, window: webview.Window): 12 | self.window = window 13 | 14 | def moveWindow(self, x: float, y: float): 15 | self.window.move(x, y) 16 | 17 | 18 | def flask_run(): 19 | app = Flask(__name__, static_folder='ui/dist', static_url_path='') 20 | c.server_app = app 21 | from server.window import window_api 22 | from server.post import post_api 23 | from server.setting import setting_api 24 | from server.plugins import plugins_api 25 | app.register_blueprint(plugins_api) 26 | app.register_blueprint(window_api) 27 | app.register_blueprint(post_api) 28 | app.register_blueprint(setting_api) 29 | app.register_blueprint(dashboard_api) 30 | app.register_blueprint(write_api) 31 | from server import static # 静态文件托管,不可删除 32 | app.run( 33 | debug=c.config['app']['debug'], 34 | port=c.APP_PORT, 35 | host=c.APP_HOST, 36 | use_reloader=False 37 | ) 38 | 39 | 40 | if __name__ == "__main__": 41 | # 检测是否安装 42 | if not c.config['app']['installed']: 43 | if not setup_wizard(): 44 | exit(1) 45 | 46 | main_window = webview.create_window( 47 | "PostSync", 48 | url=f'{c.APP_HTTP}://{c.APP_HOST}:{c.APP_PORT}', 49 | frameless=True, 50 | zoomable=False, 51 | draggable=True, 52 | resizable=True, 53 | easy_drag=False, 54 | width=c.config['view']['width'], 55 | height=c.config['view']['height'], 56 | ) 57 | webview.DRAG_REGION_SELECTOR = '#app > section > header > div > div > ul' 58 | c.main_window = main_window 59 | threading.Thread(target=flask_run, daemon=True).start() 60 | 61 | webview.start( 62 | debug=bool(c.config['app']['debug']), 63 | private_mode=False, 64 | gui='edgechromium', 65 | storage_path=c.config['data']['webview']['path'] 66 | ) 67 | -------------------------------------------------------------------------------- /common/apis.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from typing import TypedDict, Sequence, Optional, Literal, List, Dict, Union 3 | 4 | 5 | class PostPaths(TypedDict, total=True): 6 | docx: str 7 | html: str 8 | md: str 9 | 10 | 11 | class PostContents(TypedDict, total=False): 12 | docx: str 13 | html: str 14 | md: str 15 | 16 | 17 | class PostArguments(TypedDict, total=True): 18 | title: Optional[str] 19 | cover: Optional[str] 20 | tags: Optional[Sequence[str]] 21 | columns: Optional[Sequence[str]] 22 | sites: Optional[Sequence[str]] 23 | category: Optional[str] 24 | topic: Optional[str] 25 | file: str 26 | digest: Optional[str] 27 | 28 | 29 | class Post(TypedDict, total=True): 30 | title: str 31 | paths: PostPaths 32 | cover: Optional[str] 33 | tags: Optional[Sequence[str]] 34 | columns: Optional[Sequence[str]] 35 | sites: Optional[Sequence[str]] 36 | category: Optional[str] 37 | topic: Optional[str] 38 | digest: Optional[str] 39 | contents: PostContents 40 | 41 | 42 | class NameValueDict(TypedDict, total=True): 43 | name: str 44 | value: str 45 | 46 | 47 | class LocalStorageData(TypedDict, total=True): 48 | origin: str 49 | localStorage: List[NameValueDict] 50 | 51 | 52 | class CookieData(TypedDict, total=True): 53 | name: str 54 | value: str 55 | domain: str 56 | path: str 57 | expires: Union[str, int, float,None] 58 | httpOnly: Union[str,bool,None] 59 | secure: Union[str,bool,None] 60 | sameSite: Union[str,None] 61 | 62 | 63 | class StorageType(TypedDict, total=True): 64 | type: Literal["local", "cookie"] 65 | domain: str 66 | name: str 67 | value: Optional[str] 68 | 69 | 70 | class StorageData(TypedDict, total=True): 71 | cookies: List[CookieData] 72 | origins: List[LocalStorageData] 73 | 74 | 75 | 76 | 77 | 78 | -------------------------------------------------------------------------------- /common/constant.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import queue 3 | import os 4 | from collections import defaultdict 5 | from flask import Flask 6 | from htmldocx import HtmlToDocx 7 | from markdown import Markdown 8 | from markdown.extensions.codehilite import CodeHiliteExtension 9 | from markdown.extensions.fenced_code import FencedCodeExtension 10 | from webview import Window 11 | import typing as t 12 | from utils.load import load_yaml 13 | from utils.load import get_root_path 14 | from html2text import html2text 15 | 16 | # 全局常量 17 | 18 | CONFIG_FILE_PATH = 'config.yaml' 19 | FILE_ENCODING = 'utf-8' 20 | INFINITE_TIMEOUT = 10000000 21 | UNKNOWN_SITE_NAME = '未知社区' 22 | UNKNOWN_SITE_ALIAS = 'unknown' 23 | APP_PORT = 54188 24 | APP_HOST = 'localhost' 25 | APP_HTTP = 'http' 26 | APP_URL = f'{APP_HTTP}://{APP_HOST}:{APP_PORT}' 27 | 28 | HTML_EXTENSIONS = ( 29 | 'html', 30 | 'htm', 31 | 'xhtml', 32 | ) 33 | MD_EXTENSIONS = ( 34 | 'md', 35 | 'markdown', 36 | 'mdown' 37 | ) 38 | DOC_EXTENSIONS = ( 39 | 'docx', 40 | ) 41 | HTTP_SUCCESS_STATUS_CODES = ( 42 | 200, 201, 202, 203, 204, 43 | 205, 206, 207, 208, 226 44 | ) 45 | 46 | # 配置 47 | config = load_yaml(os.path.join(get_root_path(), CONFIG_FILE_PATH)) 48 | 49 | # 全局工具类 50 | md_html_parser = Markdown(extensions=[ 51 | CodeHiliteExtension(), # 代码高亮 52 | FencedCodeExtension() # 允许代码块 53 | ]) 54 | 55 | html_docx_parser = HtmlToDocx() 56 | 57 | html_md_parser = html2text 58 | 59 | # 主窗口 60 | main_window: t.Optional[Window] = None 61 | 62 | # Flask应用 63 | server_app: t.Optional[Flask] = None 64 | 65 | # 社区实例 66 | site_instances = defaultdict() 67 | 68 | # 登录状态确认队列,收到-1表示结束 69 | is_confirmed = queue.Queue() 70 | -------------------------------------------------------------------------------- /common/error.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from playwright.async_api import Error as BrowserError 3 | from playwright.async_api import TimeoutError as BrowserTimeoutError 4 | import traceback 5 | 6 | 7 | class FileNotReferencedError(Exception): 8 | """ 9 | Exception raised when a file is not referenced 10 | """ 11 | 12 | def __str__(self): 13 | return "请指定文件" 14 | 15 | 16 | class FileNotSupportedError(Exception): 17 | """ 18 | Exception raised when a file is not supported 19 | """ 20 | def __init__(self, file_type: str): 21 | self.file_type = file_type 22 | 23 | def __str__(self): 24 | return f"不支持的文件类型: {self.file_type}" 25 | 26 | 27 | class CommunityNotExistError(Exception): 28 | """ 29 | Exception raised when a community does not exist 30 | """ 31 | def __str__(self): 32 | return "社区不存在" 33 | 34 | 35 | class ConfigurationLackError(Exception): 36 | """ 37 | Exception raised when configuration lacks 38 | """ 39 | def __str__(self): 40 | return "配置文件缺失" 41 | 42 | 43 | class ConfigNotConfiguredError(Exception): 44 | """ 45 | Exception raised when config is not configured 46 | """ 47 | def __str__(self): 48 | return "未配置相关配置文件" 49 | 50 | 51 | def format_exception(exception): 52 | """ 53 | Format exception traceback 54 | :param exception: 55 | :return: 56 | """ 57 | tb = traceback.format_exception(type(exception), exception, exception.__traceback__) 58 | return ''.join(tb) 59 | 60 | 61 | class BrowserExceptionGroup(Exception): 62 | def __init__(self, exceptions): 63 | self.exceptions = exceptions 64 | super().__init__(f"发生{len(exceptions)}个错误:") 65 | 66 | def __str__(self): 67 | return '\n'.join([f"\nException {i + 1}:\n{format_exception(e)}" for i, e in enumerate(self.exceptions)]) 68 | -------------------------------------------------------------------------------- /common/handler.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import sys 3 | import json 4 | 5 | import yaml 6 | 7 | 8 | def handle_global_exception(e): 9 | return handle_global_exception_with_exception(*sys.exc_info()) 10 | 11 | 12 | def handle_global_exception_with_exception(exc_type, exc_value, exc_traceback): 13 | """ 14 | Global exception handler 15 | To be used with try-except block 16 | """ 17 | exc_json_map = {'code': -1, 'message': exc_value.__str__(), 'data': { 18 | 'type': exc_type.__name__.__str__(), 19 | 'value': exc_value.__str__(), 20 | 'traceback': exc_traceback.__str__() 21 | }} 22 | exc_json_map_msg = json.dumps(exc_json_map) 23 | print(exc_json_map_msg) 24 | return exc_json_map 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /common/result.py: -------------------------------------------------------------------------------- 1 | from flask import jsonify 2 | 3 | 4 | class Result(object): 5 | def __init__(self, code, data=None, message="成功"): 6 | self.code = code 7 | self.message = message 8 | self.data = data 9 | 10 | @staticmethod 11 | def success(data=None, message="成功"): 12 | return Result(0, data, message).__str__() 13 | 14 | @staticmethod 15 | def error(data=None, message="失败"): 16 | return Result(-1, data, message=message).__str__() 17 | 18 | @staticmethod 19 | def build(code, data=None, message="异常"): 20 | return Result(code, data, message).__str__() 21 | 22 | def __str__(self): 23 | return jsonify({"code": self.code, "message": self.message, "data": self.data}) 24 | 25 | def __repr__(self): 26 | return self.__str__() 27 | -------------------------------------------------------------------------------- /config.yaml: -------------------------------------------------------------------------------- 1 | app: 2 | debug: true 3 | desc: 软件设置 4 | installed: true 5 | data: 6 | desc: 软件数据目录 7 | executable: 8 | desc: 可执行程序路径 9 | path: C:\Program Files\Google\Chrome\Application\chrome.exe 10 | log: 11 | desc: 日志文件目录 12 | path: D:/Documents/PostSync\log 13 | path: D:/Documents/PostSync 14 | posts: 15 | desc: 文章目录 16 | path: D:/Documents/PostSync\posts 17 | storage: 18 | desc: 软件数据存储文件 19 | path: D:/Documents/PostSync\storage.json 20 | temp: 21 | desc: 临时文件目录 22 | path: D:/Documents/PostSync\temp 23 | webview: 24 | desc: 软件视图存放目录 25 | path: D:/Documents/PostSync\webview 26 | default: 27 | author: 张一风 28 | category: Python 29 | columns: 30 | - Python 31 | community: 32 | bilibili: 33 | category: 科技 34 | columns: 35 | - Python 36 | cover: null 37 | desc: B站 38 | is_login: true 39 | tags: 40 | - Python 41 | timeout: 40000 42 | topic: null 43 | cnblog: 44 | category: Python 45 | columns: 46 | - Python 47 | cover: null 48 | desc: 博客园 49 | is_login: true 50 | tags: [] 51 | timeout: 20000 52 | topic: null 53 | csdn: 54 | category: Python 55 | columns: 56 | - 解决问题 57 | cover: null 58 | desc: CSDN 59 | is_login: true 60 | tags: [] 61 | timeout: 20000 62 | topic: null 63 | juejin: 64 | category: 后端 65 | columns: 66 | - Python文章 67 | cover: null 68 | desc: 掘金 69 | is_login: true 70 | tags: 71 | - Python 72 | timeout: 40000 73 | topic: null 74 | wechat: 75 | category: null 76 | columns: [] 77 | cover: null 78 | desc: 微信公众号 79 | is_login: true 80 | tags: [] 81 | timeout: 20000 82 | topic: null 83 | zhihu: 84 | category: null 85 | columns: [] 86 | cover: null 87 | desc: 知乎 88 | is_login: true 89 | tags: [] 90 | timeout: 20000 91 | topic: null 92 | cover: static/imgs/default-cover.png 93 | desc: 默认设置 94 | devtools: false 95 | digest: 96 | desc: 文章摘要 97 | length: 100 98 | headless: false 99 | no_viewport: false 100 | tags: 101 | - Python 102 | timeout: 40000 103 | topic: null 104 | url: null 105 | user_agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, 106 | like Gecko) Chrome/128.0.0.0 Safari/537.36 107 | view: 108 | desc: 软件视图设置 109 | height: 860 110 | width: 1440 111 | -------------------------------------------------------------------------------- /docs/使用时的常见问题处理.md: -------------------------------------------------------------------------------- 1 | - Timout 超时错误 2 | - 请检查网络连接是否正常,以及服务器是否正常响应。 3 | - 请将配置文件 `config.yaml` 中的 `timeout` 值调大一些,比如 `timeout: 40000` (毫秒) 4 | -------------------------------------------------------------------------------- /docs/存储路径设置.md: -------------------------------------------------------------------------------- 1 | ### 文章存储路径 2 | 3 | config.yaml 文件中 data.posts.path 字段 4 | 5 | ### 临时托管存储路径 6 | 7 | 临时托管存储路径是 config.yaml 文件中 data.temp.path 字段 8 | 用来解决浏览器不能访问本地文件的问题 9 | 10 | ### 日志存储路径 11 | 12 | 日志存储路径是 config.yaml 文件中 data.log.path 字段 13 | 待开发 14 | 15 | ### 浏览器实例存储路径 16 | PlayWright的存储路径是 config.yaml 文件中 data.storage.path 字段 17 | Pywebview的存储路径是 config.yaml 文件中 data.webview.path 字段 18 | > PlayWright 存储路径就是上传文件的浏览器缓存文件 19 | > Pywebview 存储路径就是本地UI界面缓存目录 20 | 21 | ### 自定义存储路径注意事项 22 | - 如果自定义路径不存在会自动创建文件夹以及文件 23 | - 自定义路径一定是绝对路径,否则会造成错误 24 | -------------------------------------------------------------------------------- /docs/实现流程分析.md: -------------------------------------------------------------------------------- 1 | ## UI界面上传流程 2 | 3 | ### 创建WebView的窗口UI界面以及Flask守护线程前后端 4 | 5 | #### 创建窗口 6 | 7 | 读取配置文件中的窗口宽高,创建WebView的窗口UI界面,并设置WebView的属性 8 | 9 | #### 创建Flask守护线程 10 | 11 | 启动Flask守护线程,随窗口关闭自动关闭 12 | 通过托管前端静态文件夹实现动态前端框架的加载 13 | 绑定所有Flask的BluePrint,实现API路由功能 14 | 启动指定端口,并且该端口被前端后端共同享用,防止出现跨域问题 15 | 16 | #### 启动窗口界面 17 | 18 | 通过读取配置文件中的debug和storage_path,判断是否开启调试模式,以及窗口界面存储路径 19 | 20 | ### 前后端联调事件 21 | 22 | 前后端分离的架构使得PostSync既可以利用Python中的各种库,又可以使界面美观 23 | 24 | 虽然有人诟病Chrome内核导致内存牺牲较多 25 | 但是本软件并不致力于后台运行,在当今的硬件条件下,牺牲内存但节省时间是值得的 26 | 27 | #### 总体布局 28 | 29 | 总体布局根据vh,vw单位,设置了顶部导航栏,左侧侧边栏,右侧主体区域 30 | 会随着窗口的改变调整字体大小,左右内容区域的宽度 31 | 32 | #### 仪表盘界面 33 | 34 | 判断用户是否登录,并将其存入localStorage中 35 | 36 | 返回结果后会操作localStorage中的登录状态,并刷新页面 37 | 并且不是每次进入软件都会重新判断是否登录,而是读取localStorage中的登录状态 38 | 后端会判断Cookie或者localStorage是否符合登录成功后的条件得到是否已经登录的结果 39 | 40 | 如果未登录,则显示未登录通知以及登录按钮,点击后启动新的浏览器实例登录,并在后端能自动判断是否登录成功 41 | 42 | 当检测到登录过期或者未登录时,会进入登录流程 43 | 此时需要用户手动登录,因为验证码的形式过多,目前精力有限无法实现自动化 44 | 登陆成功后,页面会判断出响应的数据是否包含登录成功的信息 45 | > 代码中表现为在等待登录成功时会设置无限的超时时间,直到登录成功 46 | 47 | 显示文章列表,提供快捷上传与删除操作 48 | 内置vue-router实现页面的切换并提供数据交换 49 | 50 | #### 新文章界面 51 | 52 | 搭配md-editor-v3富文本编辑器,提供保存与快速导航到上传的功能 53 | 保存文章会在本地存入`config['data']['posts']['path']`工作区文件 54 | 55 | 上传图片时会将图片上传到flask托管的临时静态文件夹 56 | 同时会返回图片本地路径被替换为flask服务器路由地址的结果,以达到实时预览的效果 57 | 58 | 保存文章时会将替换的路径再替换回本地路径,以便直接上传 59 | 60 | #### 上传文章界面 61 | 62 | 由于浏览器禁止访问本地文件,因此上传文件需要借助后端API,通过pywebview提供的dialog,选择路径与封面 63 | 后端收到数据后会新建浏览器实例, 并直接传入参数打开实例上传 64 | 上传成功失败错误都会返回前端 65 | > 上传是异步的,互相不影响,可以并行执行 66 | 67 | #### 设置界面 68 | 69 | 通过后端返回的config.yaml解析后的Json数据,通过递归vue组件的算法生成设置界面控件 70 | 如果是列表(数组)字段会生成标签输入框 71 | 如果时对象则会生成子级别列表 72 | config.yaml中的父字段下desc字段表示描述字段,不可修改且没有其他作用 73 | 74 | ## 单位流程 75 | 76 | ### 登录判断流程 77 | 78 | 通过逐个分析得知,这些社区类网站的登录大部分都是会存储Cookie或者localStorage 79 | 如果加载站点后Cookie的某个name值变化或者消失,说明登录过期,此时需要重新登录 80 | 对每一个社区网站页面会首先判断本页面Cookie是否对应 81 | 接着判断本地存储的storage.json文件中是否存在,如果存在则说明已经登录 82 | 为满足则重新跳转至登录界面 83 | > 代码中表现为所有社区类的父类提供了统一的登录判断方法 84 | > 如果有不同的判断方式可以重写,不影响执行流程 85 | 86 | ### 加载配置 87 | 88 | 读取配置文件,存储在全局变量中 89 | 90 | ### 异步异常处理 91 | 92 | 如果开启调试模式,执行结束会将所有异步信息记录在异常组中并抛出 93 | 若非调试模式,则只会提示大概异常信息 94 | 95 | ### 处理文件 96 | 97 | 上传的文件会被分别解析为docx,html,md文件,并在上传文件所在的目录下保存 98 | docx文件中的图片会被提取并保存在md文件所在目录的img文件夹中,并根据顺序以 `img0.png` `img1.png` 等的格式命名 99 | html与md文件中的图片路径会被自动替换为绝对路径 100 | 101 | ### 初始化命令参数 102 | 103 | 将命令行参数解析为字典,并将其保存到社区类的实例中,供后续使用 104 | 105 | ### 初始化浏览器上下文 106 | 107 | 根据配置文件中的配置,初始化浏览器以及浏览器上下文 108 | -------------------------------------------------------------------------------- /docs/开发时的常见问题处理.md: -------------------------------------------------------------------------------- 1 | - 打包时遇到 `No module named 'xxx'` 2 | - 检查一下是否 `poetry` 安装了此依赖 3 | - 将依赖库添加到 `PostSync.spec` 文件的 `hiddenimports`参数中 -------------------------------------------------------------------------------- /docs/文章对网站收入各种合集的映射.md: -------------------------------------------------------------------------------- 1 | > None表示没有利用此栏,[数字]表示一共收录多少个 2 | 3 | ### 掘金 4 | 5 | 分类 -> 分类 \ 6 | 标签 -> 标签 \ 7 | 专栏 -> 专栏 \ 8 | 话题 -> 创作话题 \ 9 | 封面 -> 封面 \ 10 | 摘要 -> 摘要 11 | 12 | ### CSDN 13 | 14 | 分类 -> None \ 15 | 标签 -> 标签 \ 16 | 专栏 -> 专栏 \ 17 | 话题 -> None \ 18 | 封面 -> 封面 \ 19 | 摘要 -> 摘要 20 | 21 | ### 知乎 22 | 23 | 分类 -> 投稿至问题 \ 24 | 标签 -> 文章话题 \ 25 | 专栏 -> 专栏[0] \ 26 | 话题 -> None \ 27 | 封面 -> 封面 \ 28 | 摘要 -> None 29 | 30 | ### 博客园 31 | 32 | 分类 -> 分类 \ 33 | 标签 -> 标签 \ 34 | 专栏 -> 合集 \ 35 | 话题 -> None \ 36 | 封面 -> 插入题图 \ 37 | 摘要 -> 摘要 38 | 39 | ### 哔哩哔哩 40 | 41 | 分类 -> 专栏分类 \ 42 | 标签 -> 标签 \ 43 | 专栏 -> 文集[0] \ 44 | 话题 -> 专栏话题 \ 45 | 封面 -> 封面 \ 46 | 摘要 -> 摘要 47 | 48 | ### 微信公众号 49 | 50 | 分类 -> None \ 51 | 标签 -> 合集 \ 52 | 专栏 -> None \ 53 | 话题 -> None \ 54 | 封面 -> 封面 \ 55 | 摘要 -> 摘要 56 | -------------------------------------------------------------------------------- /docs/设计优缺点.md: -------------------------------------------------------------------------------- 1 | ### 设计优点 2 | 3 | #### 跨平台 4 | 5 | 目前按照跨平台的原则编写以及打包,但是受限于开发环境,并未推出MacOS系统版本 6 | 7 | 不仅是跨越系统,还可以跨越浏览器,包括Chrome,Firefox,Edge 8 | 9 | 对于博文的上传,本地只需要一篇文章,一次填写参数,即可上传到多个社区并选择相关栏目 10 | 11 | #### 自动配置 12 | 13 | `config.yaml` 文件中的配置会在运行时判断本机可执行浏览器路径,本机存储文件地址 14 | 同时打包时会将调试需要的参数设置为false 15 | 16 | #### 维护变量池 17 | 18 | `common.constant` 维护了项目中常用的常量,配置文件得到的字典,需要初始化的文档解析器,主窗口类,以及Flask的实例对象 19 | 20 | #### 设计模式 21 | 22 | 里氏代换原则 23 | > 子类型必须能够替换其父类型 24 | 25 | 单一职责原则 26 | > 一个类只负责一项职责 27 | 28 | 开闭原则 29 | > 对扩展开放,而对修改关闭 30 | 31 | 通过反射实现加载社区类实例 32 | `common.core`是所有社区工具类的父类,通过继承并覆写方法可以直接添加新的社区上传功能 33 | 34 | 模板方法模式 35 | > 模板方法模式是一种行为设计模式, 它在超类中定义了一个算法的框架, 允许子类在不修改结构的情况下重写算法的特定步骤。 36 | 37 | `common.core` 中的所有方法都是模板方法,可以根据需要重写 38 | 以及工具函数中的许多函数都包含闭包参数 39 | 40 | 外观模式 41 | > 外观模式隐藏系统的复杂性,并向客户端提供一个简单的接口。它提供了一个统一的接口,使得子系统更加容易使用。 42 | 43 | 上传文件的`common.core`模块中提供了上传文件的接口,只需要传入参数即可 44 | 提供界面或者命令行来统一参数 45 | 46 | #### 测试 47 | 48 | 提供了单元测试以及集成测试,白盒测试,黑盒测试 49 | 50 | ### 设计缺点 51 | 52 | #### 依赖库过多 53 | 54 | 项目依赖库过多,导致项目体积过大,运行速度慢 55 | 56 | #### 异常处理仍然不够具体 57 | 58 | 目前的全局异常处理过于笼统,用户使用时无法区分具体错误原因 59 | 需要进一步细化异常处理,并提供详细的错误信息提示 60 | 61 | #### 测试 62 | 63 | 测试覆盖率不足,需要进一步完善测试用例 64 | 65 | #### 流程繁琐 66 | 67 | 与文件,图片相关的流程实现较为繁琐,需要多次前后端通信 68 | -------------------------------------------------------------------------------- /entity/bilibili.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import time 3 | import typing as t 4 | from common.constant import config 5 | from common.apis import StorageType, Post 6 | from common.core import Community 7 | import json 8 | import re 9 | from common.error import BrowserTimeoutError 10 | from utils.helper import wait_random_time 11 | from utils.data import insert_html_to_element 12 | from utils.data import delete_blank_tags 13 | 14 | 15 | class Bilibili(Community): 16 | """ 17 | Bilibili社区 18 | """ 19 | 20 | site_name = "B站" 21 | site_alias = "bilibili" 22 | url_post_new = "https://member.bilibili.com/read/editor/#/web" 23 | url_redirect_login = "https://passport.bilibili.com/login" 24 | url_post_manager = "https://member.bilibili.com/platform/upload-manager/opus" 25 | url = "https://www.bilibili.com/" 26 | login_url = "https://passport.bilibili.com/login" 27 | desc = "B站官方插件" 28 | 29 | conf = { 30 | "category": "科技", 31 | "columns": ["Python"], 32 | "cover": None, 33 | "tags": ["Python"], 34 | "timeout": 40000, 35 | "topic": None, 36 | "is_login": False # 是否已经登录 37 | } 38 | 39 | async def upload(self, post: Post) -> t.AnyStr: 40 | if not config['default']['community'][self.site_alias] or not config['default']['community'][self.site_alias]['is_login']: 41 | return ("未登录") 42 | await self.before_upload(post) 43 | # 打开发布页面 44 | await self.page.goto(self.url_post_new, wait_until='load') 45 | wait_random_time() 46 | # 填写标题 47 | await self.page.locator( 48 | '#app > div > div.web-editor__wrap > div.b-read-editor > div.b-read-editor__title.mt-l > div > textarea' 49 | ).fill(self.post['title']) 50 | wait_random_time() 51 | # 处理内容 52 | content = await self.convert_html_path(self.post['contents']['html']) 53 | wait_random_time() 54 | await insert_html_to_element( 55 | self.page, 56 | "#app > div > div.web-editor__wrap > div.b-read-editor > div.b-read-editor__content.mt-m > " 57 | "div.b-read-editor__input.mb-l > div.b-read-editor__field > div > div.ql-editor", 58 | content) 59 | # 删除所有空行P标签 60 | await delete_blank_tags( 61 | self.page, 62 | "#app > div > div.web-editor__wrap > div.b-read-editor > " 63 | "div.b-read-editor__content.mt-m > div.b-read-editor__input.mb-l > " 64 | "div.b-read-editor__field > div p") 65 | # 处理图片,将其被包裹在

标签中,并添加属性,防止B站编辑器报错 66 | await self.page.evaluate( 67 | """ 68 | var imgElements = document.querySelectorAll('#app > div > div.web-editor__wrap > div.b-read-editor > div.b-read-editor__content.mt-m > div.b-read-editor__input.mb-l > div.b-read-editor__field > div img'); 69 | imgElements.forEach((imgElement)=>{ 70 | var pElement = document.createElement('p'); 71 | var newImgElement = document.createElement('img'); 72 | newImgElement.src = imgElement.src; 73 | newImgElement.alt = imgElement.alt; 74 | newImgElement.setAttribute('data-status', 'loaded'); 75 | newImgElement.setAttribute('data-w', imgElement.naturalWidth); 76 | newImgElement.setAttribute('data-h', imgElement.naturalHeight); 77 | pElement.setAttribute('class', 'normal-img focused'); 78 | pElement.setAttribute('contenteditable', 'false'); 79 | pElement.appendChild(newImgElement); 80 | imgElement.parentNode.insertBefore(pElement, imgElement); 81 | imgElement.parentNode.removeChild(imgElement); 82 | }) 83 | """ 84 | ) 85 | await self.page.locator( 86 | "#app > div > div.web-editor__wrap > div.b-read-editor > div.b-read-editor__settings.mt-m > div > div > " 87 | "div.bre-settings__sec__tit.mb-s.more" 88 | ).click() 89 | # 处理分类,选择大分类第一个 90 | category_zone = self.page.locator( 91 | "#app > div > div.web-editor__wrap > div.b-read-editor > div.b-read-editor__settings.mt-m > div > " 92 | "div:nth-child(1) > div.bre-settings__sec__ctn > div.bre-settings__categories" 93 | ) 94 | 95 | async def inner_upload_category(category: str): 96 | button = category_zone.locator( 97 | "div", has_text=re.compile(category)) 98 | await button.click() 99 | await category_zone.locator("li:nth-child(1)").click() 100 | 101 | await self.double_try_single_data( 102 | 'category', 103 | inner_upload_category, 104 | inner_upload_category 105 | ) 106 | 107 | # 处理封面 108 | async with self.page.expect_file_chooser() as select_file: 109 | await self.page.locator( 110 | "#app > div > div.web-editor__wrap > div.b-read-editor > div.b-read-editor__settings.mt-m > div > " 111 | "div:nth-child(2) > div.bre-settings__sec__ctn > div.bre-settings__coverbox.cardbox > " 112 | "div.bre-settings__coverbox__img.skeleton.img" 113 | ).click() 114 | file_chooser = await select_file.value 115 | await file_chooser.set_files(self.post['cover']) 116 | await self.page.locator( 117 | "body > div.bre-modal.bre-img-corpper-modal > div > div.bre-modal__content > " 118 | "div.bre-img-corpper-modal__footer > button.bre-btn.primary").click() 119 | 120 | # 处理话题 121 | 122 | async def inner_upload_topic(topic: str): 123 | await self.page.locator( 124 | "#app > div > div.web-editor__wrap > div.b-read-editor > div.b-read-editor__settings.mt-m > div > " 125 | "div:nth-child(3) > div.bre-settings__sec__ctn > div > div > div.bili-topic-selector__search > " 126 | "div > div > div.bili-topic-search__input > input" 127 | ).fill(topic) 128 | wait_random_time() 129 | await self.page.locator( 130 | "#app > div > div.web-editor__wrap > div.b-read-editor > div.b-read-editor__settings.mt-m > div > " 131 | "div:nth-child(3) > div.bre-settings__sec__ctn > div > div > div.bili-topic-selector__search > " 132 | "div > div > div.bili-topic-search__result > div.bili-topic-search__list > div:nth-child(1)" 133 | ).click() 134 | 135 | if self.post['topic']: 136 | await self.page.locator( 137 | "#app > div > div.web-editor__wrap > div.b-read-editor > div.b-read-editor__settings.mt-m > div > " 138 | "div:nth-child(3) > div.bre-settings__sec__ctn > div > div > div.bili-topic-selector__search > div > " 139 | "div > div.bili-topic-search__input > span" 140 | ).click() 141 | await self.double_try_single_data( 142 | 'topic', 143 | inner_upload_topic, 144 | inner_upload_topic 145 | ) 146 | 147 | # 处理标签 148 | tag_input = self.page.locator( 149 | "#app > div > div.web-editor__wrap > div.b-read-editor > div.b-read-editor__settings.mt-m > div > " 150 | "div:nth-child(6) > div.bre-settings__sec__ctn > div > form > div.bre-input.medium > input" 151 | ) 152 | for tag in self.post['tags']: 153 | await tag_input.fill(tag) 154 | await tag_input.press('Enter') 155 | # 处理专栏 156 | await self.page.locator( 157 | "#app > div > div.web-editor__wrap > div.b-read-editor > div.b-read-editor__settings.mt-m > div > " 158 | "div:nth-child(7) > div.bre-settings__sec__ctn > div.bre-settings__list > button").click() 159 | 160 | column_zone = self.page.locator( 161 | "body > div.bre-modal.bre-list-modal > div") 162 | 163 | async def inner_upload_column(column: str): 164 | pattern = re.compile(f".*{re.escape(column)}.*", re.IGNORECASE) 165 | await column_zone.get_by_text(text=pattern).first.click() 166 | 167 | await self.double_try_first_index( 168 | 'columns', 169 | inner_upload_column, 170 | inner_upload_column 171 | ) 172 | await self.page.locator( 173 | "body > div.bre-modal.bre-list-modal > div > div.bre-modal__content > div.bre-list-modal__footer > button" 174 | ).click() 175 | # 发布文章 176 | 177 | async with self.page.expect_response(re.compile("https://data.bilibili.com/v2/log/web")) as resp: 178 | await self.page.locator( 179 | "#app > div > div.web-editor__wrap > div.b-read-editor > div.b-read-editor__btns.mt-m.mb-m > " 180 | "button.bre-btn.primary.size--large" 181 | ).click() 182 | data = await resp.value 183 | data_body = await data.body() 184 | if data_body.decode('utf-8') != 'ok': 185 | raise Exception("发布失败") 186 | await self.page.goto(self.url_post_manager, wait_until='domcontentloaded') 187 | async with self.context.expect_page() as page_info: 188 | await self.page.frame_locator( 189 | "#cc-body > div.cc-content-body.upload-manage > div.opus.content > div > div > iframe").locator( 190 | "#app > div.opus-list > div.opus-list-cards > div:nth-child(1) > div.opus-card-meta > " 191 | "div.opus-card-meta__title > div" 192 | ).click() 193 | page_value = await page_info.value 194 | return page_value.url 195 | 196 | async def upload_img(self, img_path: str) -> str: 197 | async with self.page.expect_response( 198 | re.compile("https://api.bilibili.com/x/article/creative/article/upcover")) as first: 199 | async with self.page.expect_file_chooser() as fc_info: 200 | await self.page.locator( 201 | "#app > div > div.web-editor__wrap > div.b-read-editor > div.b-read-editor__content.mt-m > " 202 | "div.b-read-editor__toolbar > div > div:nth-child(13)").hover() 203 | await self.page.locator( 204 | "#app > div > div.web-editor__wrap > div.b-read-editor > div.b-read-editor__content.mt-m > " 205 | "div.b-read-editor__toolbar > div > div:nth-child(13) > div.tlbr-btn__more > div > ul > " 206 | "li:nth-child(1) > div").click() 207 | file_chooser = await fc_info.value 208 | await file_chooser.set_files(img_path) 209 | resp = await first.value 210 | resp_body = await resp.body() 211 | data = json.loads(resp_body.decode('utf-8')) 212 | return data['data']['url'] 213 | -------------------------------------------------------------------------------- /entity/cnblog.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import os 3 | from common.apis import StorageType, Post 4 | from common.core import Community 5 | from bs4 import BeautifulSoup 6 | import json 7 | from utils.helper import wait_random_time 8 | from utils.data import insert_html_content_to_frame 9 | import re 10 | import typing as t 11 | from common.constant import config 12 | 13 | 14 | class Cnblog(Community): 15 | site_name = '博客园' 16 | url_post_new = 'https://i.cnblogs.com/posts/edit' 17 | site_alias = 'cnblog' 18 | login_url = 'https://account.cnblogs.com/signin' 19 | url = 'https://www.cnblogs.com/' 20 | desc = '博客园官方插件' 21 | 22 | conf = { 23 | "category": "科技", 24 | "columns": ["Python"], 25 | "cover": None, 26 | "tags": ["Python"], 27 | "timeout": 40000, 28 | "topic": None, 29 | 30 | } 31 | 32 | async def upload(self, post: Post) -> t.AnyStr: 33 | if not config['default']['community'][self.site_alias] or not config['default']['community'][self.site_alias]['is_login']: 34 | return ("未登录") 35 | await self.before_upload(post) 36 | # 打开发布页面 37 | await self.page.goto(self.url_post_new, wait_until='domcontentloaded') 38 | wait_random_time() 39 | # 处理内容 40 | content = await self.convert_html_path(self.post['contents']['html']) 41 | # 处理标题 42 | wait_random_time() 43 | await self.page.locator("#post-title").fill(self.post['title']) 44 | wait_random_time(1, 2) 45 | await insert_html_content_to_frame(self.page, "#postBodyEditor_ifr", "#tinymce", content) 46 | await self.page.frame_locator("#postBodyEditor_ifr").locator("#tinymce").click() 47 | await self.page.locator("#summary").scroll_into_view_if_needed() 48 | # 处理分类 49 | 50 | async def inner_upload_category(category: str): 51 | await self.page.locator( 52 | "body > cnb-root > cnb-app-layout > div.main > as-split > as-split-area:nth-child(2) > div > div > cnb-spinner > div > cnb-posts-entry > cnb-post-editing-v2 > cnb-post-editor > div.panel.panel--main > cnb-category-select-panel > cnb-collapse-panel > div.panel-content.ng-tns-c57-9.ng-trigger.ng-trigger-openClosePanel.cnb-panel-body > div > div > cnb-post-category-select > cnb-tree-category-select > div > nz-tree-select > div").click() 53 | await self.page.locator( 54 | "#cdk-overlay-1").locator( 55 | "span", has_text=re.compile(category, re.IGNORECASE)).first.click() 56 | 57 | await self.double_try_single_data( 58 | 'category', 59 | inner_upload_category, 60 | inner_upload_category 61 | ) 62 | await self.page.locator( 63 | "#tags > div > div > nz-select > nz-select-top-control > nz-select-search > input").click() 64 | tag_selector = self.page.locator(".cdk-virtual-scroll-content-wrapper") 65 | # 处理标签 66 | 67 | async def inner_upload_tag(tag: str): 68 | await self.page.locator( 69 | "#tags > div > div > nz-select > nz-select-top-control > nz-select-search > input").fill(tag) 70 | await tag_selector.locator("span", has_text=re.compile(tag, re.IGNORECASE)).first.click() 71 | 72 | await self.double_try_data( 73 | 'tags', 74 | inner_upload_tag, 75 | inner_upload_tag 76 | ) 77 | # 处理合集(专栏) 78 | await self.page.locator("div").filter(has_text=re.compile(r"^添加到合集$")).click() 79 | column_selector = self.page.locator( 80 | "body > cnb-root > cnb-app-layout > div.main > as-split > as-split-area:nth-child(2) > div > div > cnb-spinner > div > cnb-posts-entry > cnb-post-editing-v2 > cnb-post-editor > div.panel.panel--main > cnb-collection-selector > cnb-collapse-panel > div.panel-content.ng-tns-c57-16.ng-trigger.ng-trigger-openClosePanel.cnb-panel-body > div") 81 | for column in self.post['columns']: 82 | await column_selector.locator("span", has_text=re.compile(column, re.IGNORECASE)).first.click() 83 | # 处理封面 84 | if self.post['cover'] is not None and self.post['cover'] != '' and os.path.exists(self.post['cover']): 85 | await self.page.locator( 86 | ".featured-image-input__actions > div").click() 87 | async with self.page.expect_file_chooser() as fc_info: 88 | await self.page.locator("#modal-upload-featured-image > div > div > div:nth-child(3) > button").click() 89 | file_chooser = await fc_info.value 90 | await file_chooser.set_files(self.post['cover']) 91 | wait_random_time() 92 | await self.page.get_by_role("button", name="取消").last.click() 93 | # 处理摘要 94 | await self.page.locator("#summary").fill(self.post['digest']) 95 | 96 | wait_random_time() 97 | async with self.page.expect_response("https://i.cnblogs.com/api/posts") as response: 98 | submit_button = self.page.locator( 99 | "body > cnb-root > cnb-app-layout > div.main > as-split > as-split-area:nth-child(2) > div > div > " 100 | "cnb-spinner > div > cnb-posts-entry > cnb-post-editing-v2 > cnb-post-editor > div.panel--bottom > " 101 | "cnb-spinner > div > cnb-submit-buttons > " 102 | "button.cnb-button.d-inline-flex.align-items-center.ng-star-inserted" 103 | ) 104 | await submit_button.dblclick() 105 | data = await response.value 106 | data = await data.body() 107 | data = json.loads(data.decode('utf-8')) 108 | return 'https:' + data['url'] 109 | 110 | async def convert_html_path(self, content: str) -> str: 111 | await self.page.locator("#editor-switcher").click() 112 | await self.page.locator("#cdk-overlay-0 > div > ul > li:nth-child(2)").click() 113 | soup = BeautifulSoup(content, 'html.parser') 114 | img_tags = soup.find_all('img') 115 | for img in img_tags: 116 | img['src'] = await self.upload_img(img['src']) 117 | return str(soup) 118 | 119 | async def upload_img(self, img_path: str) -> str: 120 | await self.page.locator( 121 | "#editor-wrapper > cnb-tinymce5 > cnb-spinner > div > div.tox.tox-tinymce > div.tox-editor-container > " 122 | "div.tox-editor-header > div.tox-toolbar-overlord > div:nth-child(2) > div:nth-child(2) > " 123 | "button:nth-child(1) > span").click() 124 | await self.page.locator('div > div.tox-dialog__body-nav').locator('div', has_text='上传').click() 125 | async with self.page.expect_response("https://upload.cnblogs.com/imageuploader/CorsUpload") as first: 126 | async with self.page.expect_file_chooser() as fc_info: 127 | await self.page.locator("div > div.tox-dialog__body-content > div > div > div > div > button").click() 128 | file_chooser = await fc_info.value 129 | await file_chooser.set_files(img_path) 130 | resp = await first.value 131 | resp_body = await resp.body() 132 | data = json.loads(resp_body.decode('utf-8')) 133 | await self.page.locator( 134 | "body > div.tox.tox-silver-sink.tox-tinymce-aux > div > div.tox-dialog > div.tox-dialog__header > button " 135 | "> div").click() 136 | return data['message'] 137 | -------------------------------------------------------------------------------- /entity/csdn.py: -------------------------------------------------------------------------------- 1 | import typing as t 2 | 3 | from common.apis import StorageType, Post 4 | from common.core import Community 5 | from bs4 import BeautifulSoup 6 | import json 7 | import re 8 | from utils.helper import wait_random_time 9 | from common.error import BrowserTimeoutError 10 | from utils.file import get_path 11 | from utils.data import on_click_by_selector 12 | from common.constant import config 13 | 14 | 15 | class Csdn(Community): 16 | site_name = 'CSDN' 17 | url_post_new = 'https://editor.csdn.net/md/' 18 | url_redirect_login = 'https://passport.csdn.net/login' 19 | site_alias = 'csdn' 20 | url = "https://www.csdn.net/" 21 | login_url = "https://passport.csdn.net/login?code=applets" 22 | desc = 'CSDN官方插件' 23 | 24 | conf = { 25 | "category": "科技", 26 | "columns": ["Python"], 27 | "cover": None, 28 | "tags": ["Python"], 29 | "timeout": 40000, 30 | "topic": None, 31 | } 32 | 33 | async def upload(self, post: Post) -> t.AnyStr: 34 | if not config['default']['community'][self.site_alias] or not config['default']['community'][self.site_alias]['is_login']: 35 | return ("未登录") 36 | await self.before_upload(post) 37 | # 打开发布页面 38 | await self.page.goto(self.url_post_new) 39 | await self.page.locator(".editor__inner").fill("") 40 | wait_random_time() 41 | # 处理内容 42 | content = await self.convert_html_path(self.post['contents']['html']) 43 | # 输入标题 44 | await self.page.locator(".article-bar__title").fill(self.post['title']), 45 | # 输入内容 46 | await self.page.locator(".editor__inner").fill(content) 47 | await self.page.get_by_role("button", name="发布文章").click() 48 | cover_img = self.page.locator( 49 | "body > div.app.app--light > div.modal > div > div.modal__inner-2 > div.modal__content > div:nth-child(3) " 50 | "> div > div.preview-box > img" 51 | ) 52 | cover_attr = await cover_img.get_attribute("src") 53 | if cover_attr.strip() != "": 54 | await on_click_by_selector(self.page, ".btn-remove-coverimg") 55 | # 封面处理 56 | async with self.page.expect_file_chooser() as fc_info: 57 | try: 58 | await self.page.locator(".preview-box").click() 59 | except BrowserTimeoutError: 60 | await on_click_by_selector(self.page, ".btn-remove-coverimg") 61 | await self.page.locator(".preview-box").click() 62 | file_chooser = await fc_info.value 63 | await file_chooser.set_files(self.post['cover']) 64 | # 输入摘要 65 | wait_random_time() 66 | await self.page.locator(".el-textarea__inner").fill(self.post['digest']) 67 | # 标签处理 68 | wait_random_time() 69 | await self.page.locator(".mark_selection_title_el_tag > .tag__btn-tag").click() 70 | tag_input = self.page.locator(".el-input--suffix > .el-input__inner") 71 | column_selector = self.page.locator( 72 | ".el-autocomplete-suggestion__list") 73 | 74 | for tag in self.post['tags']: 75 | await tag_input.fill(tag) 76 | try: 77 | await column_selector.locator("li", has_text=re.compile(tag, re.IGNORECASE)).first.click() 78 | except BrowserTimeoutError: 79 | await column_selector.locator("li").first.click() 80 | await self.page.locator(".mark_selection_box_body > button").click() 81 | wait_random_time() 82 | # 专栏处理 83 | column_selector = self.page.locator(".tag__options-list") 84 | 85 | async def inner_upload_column(column: str): 86 | await self.page.locator(".tag__item-list > .tag__btn-tag").click() 87 | await column_selector.locator("span", has_text=re.compile(column, re.IGNORECASE)).first.click() 88 | 89 | await self.double_try_data( 90 | 'columns', 91 | inner_upload_column, 92 | inner_upload_column, 93 | ) 94 | 95 | await self.page.locator(".tag__options-txt > .modal__close-button").click() 96 | # 点击发布按钮 97 | wait_random_time() 98 | await self.page.get_by_label("Insert publishArticle").get_by_role("button", name="发布文章").click() 99 | # 文章链接 100 | wait_random_time() 101 | async with self.page.expect_response("**/saveArticle") as response_info: 102 | pass 103 | wait_random_time() 104 | data = await response_info.value 105 | data_body = await data.body() 106 | data = json.loads(data_body.decode('utf-8')) 107 | post_url = data['data']['url'] 108 | return post_url 109 | 110 | async def convert_html_path(self, content: str) -> str: 111 | soup = BeautifulSoup(content, 'html.parser') 112 | img_tags = soup.find_all('img') 113 | for img in img_tags: 114 | img['src'] = await self.upload_img(img['src']) 115 | return str(soup) 116 | 117 | async def upload_img(self, img_path: str) -> str: 118 | img_path = get_path(img_path) 119 | async with self.page.expect_response("https://csdn-img-blog.obs.cn-north-4.myhuaweicloud.com/") as first: 120 | async with self.page.expect_file_chooser() as fc_info: 121 | await self.page.get_by_role("button", name="图片 图片").dblclick() 122 | await self.page.locator(".uploadPicture > input").dblclick() 123 | file_chooser = await fc_info.value 124 | await file_chooser.set_files(img_path) 125 | resp = await first.value 126 | resp_body = await resp.body() 127 | data = json.loads(resp_body.decode('utf-8')) 128 | return data['data']['imageUrl'] 129 | -------------------------------------------------------------------------------- /entity/juejin.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import re 4 | import typing as t 5 | from common.apis import StorageType, Post 6 | from utils.helper import wait_random_time 7 | from common.core import Community 8 | from common.constant import config 9 | 10 | 11 | class Juejin(Community): 12 | """ 13 | Juejin Community 14 | """ 15 | 16 | conf = { 17 | "category": "科技", 18 | "columns": ["Python"], 19 | "cover": None, 20 | "tags": ["Python"], 21 | "timeout": 40000, 22 | "topic": None, 23 | 24 | } 25 | 26 | site_name = "稀土掘金" 27 | site_alias = "juejin" 28 | url_post_new = "https://juejin.cn/editor/drafts/new" 29 | url_redirect_login = "https://juejin.cn/login" 30 | login_url = "https://juejin.cn/login" 31 | url = "https://www.juejin.cn" 32 | desc = "稀土掘金官方插件" 33 | 34 | async def upload(self, post: Post) -> t.AnyStr: 35 | if not config['default']['community'][self.site_alias] or not config['default']['community'][self.site_alias]['is_login']: 36 | return "未登录" 37 | await self.before_upload(post) 38 | # 打开发布页面 39 | await self.page.goto(self.url_post_new) 40 | # 处理图片路径 41 | content = await self.convert_html_path(self.post['contents']['html']) 42 | # 输入标题 43 | await self.page.locator(".title-input").fill(self.post['title']) 44 | # 选中内容输入框 45 | await self.page.get_by_role("textbox").nth(1).fill(content) 46 | 47 | async def choose_cover(): 48 | async with self.page.expect_file_chooser() as fc_info: 49 | await self.page.get_by_role("button", name="上传封面").click() 50 | file_chooser = await fc_info.value 51 | await file_chooser.set_files(self.post['cover']) 52 | 53 | # 点击发布按钮 54 | await self.page.get_by_role("button", name="发布").click() 55 | 56 | # 选择分类 57 | 58 | async def inner_upload_category(category: str): 59 | if not category: 60 | return "未选择分类" 61 | category_str = str(category).strip() 62 | await (self.page.locator(".category-list") 63 | .locator( 64 | "div", 65 | has_text=re.compile(str(category_str), re.IGNORECASE) 66 | ).click()) 67 | 68 | await self.double_try_single_data( 69 | 'category', 70 | inner_upload_category, 71 | inner_upload_category 72 | ) 73 | 74 | # 选择标签 75 | await self.page.get_by_text("请搜索添加标签").click() 76 | tag_input = self.page.get_by_role( 77 | "banner").get_by_role("textbox").nth(1) 78 | 79 | # 逐个搜索添加标签 80 | 81 | tag_zone = self.page.locator( 82 | "body > div.byte-select-dropdown.byte-select-dropdown--multiple.tag-select-add-margin > div" 83 | ) 84 | 85 | async def inner_upload_tag(inner_tag: str): 86 | await tag_input.fill(inner_tag) 87 | wait_random_time() 88 | await tag_zone.locator("li").first.click() 89 | 90 | await self.double_try_data( 91 | 'tags', 92 | inner_upload_tag, 93 | inner_upload_tag 94 | ) 95 | # 输入摘要 96 | await self.page.get_by_role("banner").locator("textarea").fill(self.post['digest']) 97 | wait_random_time() 98 | # 选择封面 99 | if self.post['cover'] and os.path.exists(self.post['cover']): 100 | await choose_cover() 101 | # 点击空白处防止遮挡 102 | # 选择专栏 103 | column_selector = self.page.locator(".byte-select-dropdown").last 104 | column_input = self.page.get_by_role( 105 | "banner").get_by_role("textbox").nth(2) 106 | 107 | async def inner_upload_column(column: str): 108 | await column_input.fill(column) 109 | await column_selector.locator("li", has_text=re.compile(column, re.IGNORECASE)).first.click() 110 | 111 | await self.double_try_data( 112 | 'columns', 113 | inner_upload_column, 114 | inner_upload_column 115 | ) 116 | 117 | # 选择话题 118 | if self.post['topic']: 119 | topic_selector = self.page.locator(".topic-select-dropdown") 120 | topic_input = self.page.get_by_role( 121 | "banner").get_by_role("textbox").nth(3) 122 | await topic_input.fill(self.post['topic']) 123 | await topic_selector.locator("span", has_text=re.compile(self.post['topic'], re.IGNORECASE)).first.click() 124 | # 点击发布按钮 125 | wait_random_time() 126 | 127 | async def inner_click_publish(): 128 | await self.page.locator("#juejin-web-editor > div.edit-draft > div > header > div.right-box > div.publish-popup.publish-popup.with-padding.active > div > div.footer > div > button.ui-btn.btn.primary.medium.default").click() 129 | 130 | await self.double_try( 131 | inner_click_publish, 132 | inner_click_publish 133 | ) 134 | await self.page.wait_for_url(re.compile(r"\/published")) 135 | # 获取文章链接 136 | res = self.page.expect_response("**/article/detail*") 137 | first = await res.__aenter__() 138 | resp = await first.value 139 | resp_body = await resp.body() 140 | data = json.loads(resp_body.decode('utf-8')) 141 | await res.__aexit__(None, None, None) 142 | return 'https://juejin.cn/post/' + data['data']['article_id'] 143 | 144 | async def upload_img(self, img_path: str) -> str: 145 | async with self.page.expect_response("**/get_img_url*") as first: 146 | async with self.page.expect_file_chooser() as fc_info: 147 | await self.page.locator("div:nth-child(6) > svg").first.click() 148 | file_chooser = await fc_info.value 149 | await file_chooser.set_files(img_path) 150 | resp = await first.value 151 | resp_body = await resp.body() 152 | data = json.loads(resp_body.decode('utf-8')) 153 | return data['data']['main_url'] 154 | -------------------------------------------------------------------------------- /entity/wechat.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import re 3 | import typing as t 4 | from utils.helper import wait_random_time 5 | from common.constant import config 6 | from common.core import Community 7 | import json 8 | from playwright.async_api import Browser, BrowserContext 9 | from common.apis import Post, StorageType 10 | from common.constant import config 11 | 12 | 13 | class Wechat(Community): 14 | url_post_new = "https://mp.weixin.qq.com/" 15 | site_name = "公众号" 16 | site_alias = "wechat" 17 | url = "https://mp.weixin.qq.com/" 18 | login_url = "https://mp.weixin.qq.com/" 19 | desc = "微信公众号官方插件" 20 | 21 | conf = { 22 | "category": "科技", 23 | "columns": ["Python"], 24 | "cover": None, 25 | "tags": ["Python"], 26 | "timeout": 40000, 27 | "topic": None 28 | } 29 | 30 | def __init__(self, browser: "Browser", context: "BrowserContext", **kwargs): 31 | super().__init__(browser, context, **kwargs) 32 | self.origin_src = None 33 | 34 | async def upload(self, post: Post) -> t.AnyStr: 35 | if not config['default']['community'][self.site_alias] or not config['default']['community'][self.site_alias]['is_login']: 36 | return "未登录" 37 | await self.before_upload(post) 38 | await self.page.goto(Wechat.url_post_new) 39 | async with self.context.expect_page() as new_page: 40 | await self.page.locator('.new-creation__menu > div:nth-child(4)').click() 41 | await self.page.close() 42 | self.page = await new_page.value 43 | await self.page.get_by_role("listitem", name="文档导入").click() 44 | async with self.page.expect_file_chooser() as fc_info: 45 | await self.page.locator('#js_import_file_container label').click() 46 | file_chooser = await fc_info.value 47 | async with self.page.expect_response("https://mp.weixin.qq.com/advanced/mplog?action**") as response_info: 48 | await file_chooser.set_files(self.post['paths']['html'].replace('.html', '.docx')) 49 | await response_info.value 50 | # 填写作者 51 | await self.page.locator("#author").fill(config['default']['author']) 52 | # 上传封面 53 | async with self.page.expect_file_chooser() as fc_info: 54 | wait_random_time() 55 | await self.page.locator("#js_cover_area").scroll_into_view_if_needed() 56 | wait_random_time() 57 | await self.page.locator("#js_cover_area").hover() 58 | wait_random_time() 59 | await self.page.locator("#js_cover_null > ul > li:nth-child(2) > a").click() 60 | wait_random_time() 61 | await self.page.locator("#vue_app label").nth(1).click() 62 | file_chooser = await fc_info.value 63 | async with self.page.expect_response( 64 | "https://mp.weixin.qq.com/cgi-bin/filetransfer?action=upload**"): 65 | await file_chooser.set_files(self.post['cover']) 66 | if await self.page.locator( 67 | "#js_image_dialog_list_wrp > div > div:nth-child(2) > i > .image_dialog__checkbox").is_enabled(): 68 | await self.page.locator("#js_image_dialog_list_wrp > div > div:nth-child(2)").click() 69 | await self.page.locator( 70 | "#vue_app > div:nth-child(3) > div.weui-desktop-dialog__wrp.weui-desktop-dialog_img-picker > div > div.weui-desktop-dialog__ft > div:nth-child(1) > button").click() 71 | wait_random_time() 72 | await self.page.locator( 73 | "#vue_app > div:nth-child(3) > div.weui-desktop-dialog__wrp > div > div.weui-desktop-dialog__ft > div:nth-child(2) > button").click() 74 | wait_random_time() 75 | # 填写摘要 76 | await self.page.locator("#js_description").fill(self.post['digest']) 77 | # 填写合集(标签) 78 | await self.page.locator("#js_article_tags_area > label > div > span").click() 79 | tags_input = self.page.locator( 80 | "#vue_app > div:nth-child(3) > div.weui-desktop-dialog__wrp.article_tags_dialog.js_article_tags_dialog > " 81 | "div > div.weui-desktop-dialog__bd > div > form > div.weui-desktop-form__control-group > div > " 82 | "div.tags_input_wrap.js_not_hide_similar_tags > div:nth-child(1) > div > span > " 83 | "span.weui-desktop-form-tag__wrp > div > span > input") 84 | for tag in self.post['tags']: 85 | await tags_input.fill(tag) 86 | await self.page.locator( 87 | "#vue_app > div:nth-child(3) > div.weui-desktop-dialog__wrp.article_tags_dialog" 88 | ".js_article_tags_dialog > div > div.weui-desktop-dialog__bd > div > form > " 89 | "div.weui-desktop-form__control-group > div > div.tags_input_wrap.js_not_hide_similar_tags > " 90 | "div.weui-desktop-dropdown-menu.article_tags_sug > ul > li > div").click() 91 | await self.page.locator( 92 | "#vue_app > div:nth-child(3) > div.weui-desktop-dialog__wrp.article_tags_dialog.js_article_tags_dialog > " 93 | "div > div.weui-desktop-dialog__ft > div:nth-child(1) > button").click() 94 | # 保存草稿 95 | await self.page.locator("#js_submit > button").click() 96 | async with self.page.expect_response("https://mp.weixin.qq.com/cgi-bin/masssend?**") as response_info: 97 | response = await response_info.value 98 | data_body = await response.body() 99 | data = json.loads(data_body.decode('utf-8')) 100 | if data['base_resp']['ret'] == 0: 101 | await self.page.locator("#js_preview > button").click() 102 | await self.page.wait_for_load_state() 103 | return self.page.url 104 | # 获取链接 105 | return "上传失败" 106 | -------------------------------------------------------------------------------- /entity/zhihu.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import re 3 | import typing as t 4 | from common.apis import Post, StorageType 5 | from utils.helper import wait_random_time 6 | from common.core import Community 7 | from bs4 import BeautifulSoup 8 | import json 9 | from playwright.async_api import Browser, BrowserContext 10 | from common.constant import config 11 | 12 | 13 | class Zhihu(Community): 14 | url_post_new = "https://zhuanlan.zhihu.com/write" 15 | login_url = "https://www.zhihu.com/signin" 16 | url_redirect_login = "https://www.zhihu.com/signin" 17 | site_name = "知乎" 18 | site_alias = "zhihu" 19 | url = "https://www.zhihu.com" 20 | desc = "知乎官方插件" 21 | 22 | conf = { 23 | "category": "科技", 24 | "columns": ["Python"], 25 | "cover": None, 26 | "tags": ["Python"], 27 | "timeout": 40000, 28 | "topic": None, 29 | 30 | } 31 | 32 | def __init__(self, browser: "Browser", context: "BrowserContext", **kwargs): 33 | super().__init__(browser, context, **kwargs) 34 | self.pic_nums = 0 # 正在处理的图片数量 35 | self.origin_src = None 36 | 37 | async def upload(self, post: Post) -> t.AnyStr: 38 | if not config['default']['community'][self.site_alias] or not config['default']['community'][self.site_alias]['is_login']: 39 | return "未登录" 40 | await self.before_upload(post) 41 | await self.page.goto(self.url_post_new) 42 | # 上传图片 43 | await self.page.get_by_label("图片").click() 44 | content = await self.convert_html_path(self.post['contents']['html']) 45 | await self.page.get_by_label("关闭").click() 46 | # 上传内容 47 | resp = await self.page.request.fetch( 48 | "https://zhuanlan.zhihu.com/api/articles/drafts", 49 | method="POST", 50 | data={ 51 | "content": content, 52 | "del_time": "0", 53 | "table_of_contents": "false" 54 | }, 55 | ignore_https_errors=True 56 | ) 57 | resp_body = await resp.body() 58 | data = json.loads(resp_body.decode('utf-8')) 59 | await self.page.goto("https://zhuanlan.zhihu.com/p/" + str(data['id']) + "/edit") 60 | # 输入标题 61 | await self.page.get_by_placeholder("请输入标题(最多 100 个字)").type(self.post['title']) 62 | # 上传封面 63 | async with self.page.expect_file_chooser() as fc_info: 64 | await self.page.locator("label").filter(has_text="添加文章封面").click() 65 | file_chooser = await fc_info.value 66 | await file_chooser.set_files(self.post['cover']) 67 | # 这里的分类当作问题投稿 68 | await self.page.locator(".ddLajxN_Q0AuobBZjX9m > button").first.click() 69 | await self.page.get_by_placeholder("请输入关键词查找问题").click() 70 | await self.page.get_by_placeholder("请输入关键词查找问题").fill(self.post['category']) 71 | wait_random_time(1, 2) 72 | 73 | async def inner_upload_category(): 74 | await self.page.locator(".Creator-SearchBar-searchIcon").dblclick() 75 | await self.page.locator(".css-1335jw2 > div > .Button").first.click() 76 | await self.page.get_by_role("button", name="确定").click() 77 | 78 | await self.double_try( 79 | inner_upload_category, 80 | inner_upload_category 81 | ) 82 | 83 | async def inner_upload_tag(tag): 84 | await self.page.get_by_role("button", name="添加话题").click() 85 | await self.page.get_by_placeholder("搜索话题").fill(tag) 86 | await self.page.locator(".css-ogem9c > button").first.click() 87 | 88 | await self.double_try_data( 89 | 'tags', 90 | inner_upload_tag, 91 | inner_upload_tag 92 | ) 93 | 94 | async def inner_upload_column(column): 95 | await self.page.locator("#root > div > main > div > div.WriteIndexLayout-main.WriteIndex.css-1losy9j > div.css-1so3nbl > div.PostEditor-wrapper > div.css-13mrzb0 > div:nth-child(6) > div > div.Popover.ddLajxN_Q0AuobBZjX9m.css-dlnfsc").click() 96 | # 构建正则表达式 97 | pattern = re.compile(f".*{re.escape(column)}.*", re.IGNORECASE) 98 | # 使用正则表达式匹配 99 | await self.page.get_by_role("option", name=pattern).click() 100 | 101 | await self.page.locator("label").filter(has_text=re.compile(r"^发布到专栏$")).click() 102 | await self.double_try_first_index( 103 | 'columns', 104 | inner_upload_column, 105 | inner_upload_column 106 | ) 107 | esc_alert = self.page.locator( 108 | "body > div:nth-child(31) > div > div > div > div.Modal.Modal--fullPage.DraftHistoryModal > button" 109 | ) 110 | 111 | wait_random_time() 112 | if await esc_alert.is_visible(): 113 | await esc_alert.click() 114 | 115 | await self.page.get_by_placeholder("请输入标题(最多 100 个字)").type(self.post['title']) 116 | 117 | # 发布文章 118 | await self.page.locator( 119 | "#root > div > main > div > div.WriteIndexLayout-main.WriteIndex.css-1losy9j > div.css-1so3nbl > " 120 | "div.PostEditor-wrapper > div.css-13mrzb0 > div.css-1ppjin3 > div > " 121 | "button.Button.css-d0uhtl.FEfUrdfMIKpQDJDqkjte.Button--primary.Button--blue.epMJl0lFQuYbC7jrwr_o" 122 | ".JmYzaky7MEPMFcJDLNMG" 123 | ).click() 124 | await self.page.wait_for_url("https://zhuanlan.zhihu.com/p/*") 125 | return self.page.url 126 | 127 | async def check_response(self, response): 128 | if response.url.startswith("https://api.zhihu.com/images/"): 129 | resp_body = await response.body() 130 | data = json.loads(resp_body.decode('utf-8')) 131 | if data['status'] == 'success': 132 | self.origin_src = data['original_src'] 133 | 134 | async def upload_img(self, img_path: str) -> str: 135 | self.page.on("response", self.check_response) 136 | async with self.page.expect_response("https://api.zhihu.com/images"): 137 | async with self.page.expect_file_chooser() as fc_info: 138 | if self.pic_nums != 0: 139 | await self.page.locator("body > div:nth-child(28) > div > div > div > " 140 | "div.Modal.Modal--default.css-zelv4t > div > div > div > div.css-1jf2703 " 141 | "> div.css-s3axrf > div > div").first.click() 142 | else: 143 | await self.page.locator(".css-n71hcb").click() 144 | file_chooser = await fc_info.value 145 | await file_chooser.set_files(img_path) 146 | self.pic_nums += 1 147 | while self.origin_src is None: 148 | await asyncio.sleep(0.1) 149 | self.origin_src: str 150 | return self.origin_src 151 | 152 | async def convert_html_path(self, content: str) -> str: 153 | soup = BeautifulSoup(content, 'html.parser') 154 | img_tags = soup.find_all('img') 155 | for img in img_tags: 156 | img['src'] = await self.upload_img(img['src']) 157 | return str(soup) 158 | -------------------------------------------------------------------------------- /make.py: -------------------------------------------------------------------------------- 1 | """ 2 | 这个文件用来打包项目 3 | """ 4 | import os 5 | import subprocess 6 | import copy 7 | import yaml 8 | from yaml import Dumper 9 | from utils.load import get_root_path 10 | from common import constant as c 11 | import platform 12 | import sys 13 | 14 | # 需要打包的spec文件 15 | specs = ['PostSync.spec'] 16 | 17 | if __name__ == '__main__': 18 | if sys.argv.__len__() > 1 and sys.argv[1] != 'no-ui': 19 | # 切换到UI目录打包前端 20 | os.chdir(os.path.join(get_root_path(), 'ui')) 21 | os.system('npm run build') 22 | os.chdir(get_root_path()) 23 | # 修改config.yaml中的参数 24 | config_backup = copy.deepcopy(c.config) 25 | c.config['default']['headless'] = True 26 | c.config['app']['debug'] = False 27 | c.config['app']['installed'] = False 28 | with open(get_root_path() + '/config.yaml', 'w', encoding='utf-8') as file: 29 | yaml.dump(c.config, file, default_flow_style=False, encoding='utf-8', Dumper=Dumper, sort_keys=False, 30 | allow_unicode=True) 31 | for spec in specs: 32 | # 定义命令 33 | command = ['pyinstaller', '-y', spec] 34 | # 执行命令 35 | subprocess.run(command) 36 | # 还原config.yaml 37 | file.seek(0) 38 | yaml.dump(config_backup, file, default_flow_style=False, encoding='utf-8', Dumper=Dumper, sort_keys=False, 39 | allow_unicode=True) 40 | -------------------------------------------------------------------------------- /make_local.py: -------------------------------------------------------------------------------- 1 | """ 2 | 这个文件用来打包项目 3 | """ 4 | import os 5 | import subprocess 6 | import copy 7 | import yaml 8 | from yaml import Dumper 9 | from utils.load import get_root_path 10 | from common import constant as c 11 | import platform 12 | import sys 13 | 14 | # 需要打包的spec文件 15 | specs = ['PostSync.spec'] 16 | 17 | if __name__ == '__main__': 18 | if sys.argv.__len__() > 1 and sys.argv[1] != 'no-ui': 19 | # 切换到UI目录打包前端 20 | os.chdir(os.path.join(get_root_path(), 'ui')) 21 | os.system('npm run build') 22 | os.chdir(get_root_path()) 23 | # 修改config.yaml中的参数 24 | config_backup = copy.deepcopy(c.config) 25 | c.config['default']['headless'] = False 26 | c.config['app']['debug'] = False 27 | with open(get_root_path() + '/config.yaml', 'w', encoding='utf-8') as file: 28 | yaml.dump(c.config, file, default_flow_style=False, encoding='utf-8', Dumper=Dumper, sort_keys=False, 29 | allow_unicode=True) 30 | for spec in specs: 31 | # 定义命令 32 | command = ['pyinstaller', '-y', spec] 33 | # 执行命令 34 | subprocess.run(command) 35 | # 还原config.yaml 36 | file.seek(0) 37 | yaml.dump(config_backup, file, default_flow_style=False, encoding='utf-8', Dumper=Dumper, sort_keys=False, 38 | allow_unicode=True) 39 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "postsync" 3 | version = "1.0.0" 4 | description = "促进技术文章发展" 5 | authors = ["XFS-小风 "] 6 | readme = "README.md" 7 | 8 | [tool.poetry.dependencies] 9 | python = ">=3.8,<3.13" 10 | pytest = "^8.2.2" 11 | playwright = "^1.44.0" 12 | faker = "^25.8.0" 13 | pyyaml = "^6.0.1" 14 | markdown = "^3.6" 15 | beautifulsoup4 = "^4.12.3" 16 | argparse = "^1.4.0" 17 | nest-asyncio = "^1.6.0" 18 | pip = "^24.0" 19 | pyinstaller = "^6.8.0" 20 | pytest-asyncio = "^0.23.7" 21 | python-docx = "^1.1.2" 22 | pyperclip = "^1.9.0" 23 | tomd = "^0.1.3" 24 | pydocx = "^0.9.10" 25 | html2text = "^2024.2.26" 26 | html3docx = "^0.0.9" 27 | pillow = "^10.4.0" 28 | flask = "^3.0.3" 29 | pywebview = "^5.2" 30 | asgiref = "^3.8.1" 31 | snownlp = "^0.12.3" 32 | 33 | 34 | [[tool.poetry.source]] 35 | name = "mirrors" 36 | url = "https://pypi.tuna.tsinghua.edu.cn/simple/" 37 | priority = "primary" 38 | 39 | [build-system] 40 | requires = ["poetry-core"] 41 | build-backend = "poetry.core.masonry.api" 42 | -------------------------------------------------------------------------------- /server/dashboard.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import asyncio 3 | import json 4 | import os 5 | from flask import Blueprint, request 6 | import yaml 7 | from utils.storage import storage_config 8 | from utils.browser import get_community_instance, create_context 9 | import common.constant as c 10 | from utils.file import get_file_name_without_ext 11 | from common.constant import config 12 | from common.error import BrowserExceptionGroup 13 | from common.result import Result 14 | from utils.load import get_root_path 15 | import queue 16 | 17 | dashboard_api = Blueprint('dashboard_api', __name__, 18 | url_prefix='/api/dashboard') 19 | 20 | 21 | @dashboard_api.route('/login/check', methods=['GET']) 22 | async def check_login(): 23 | results = [] 24 | for item in config['default']['community'].keys(): 25 | results.append({ 26 | 'alias': item, 27 | 'name': config['default']['community'][item]['desc'], 28 | 'status': config['default']['community'][item]['is_login'] 29 | }) 30 | return Result.success(message='登录状态检查成功', data=results) 31 | 32 | 33 | @dashboard_api.route('/login/reset', methods=['POST']) 34 | async def reset_login(): 35 | site = json.loads(request.get_data().decode('utf-8'))['name'] 36 | config['default']['community'][site]['is_login'] = False 37 | storage_config() 38 | return Result.success(message='重置成功') 39 | 40 | 41 | @dashboard_api.route('/login/once', methods=['POST']) 42 | async def login_once(): 43 | browser, context, asp = await create_context(headless=False) 44 | site = json.loads(request.get_data().decode('utf-8'))['name'] 45 | c.login_site_context = context 46 | site_instance = get_community_instance( 47 | site, browser, context) 48 | from playwright._impl._errors import TargetClosedError 49 | try: 50 | ret = await site_instance.login() 51 | if not ret: 52 | return Result.error(message='登录失败') 53 | except TargetClosedError as e: 54 | pass 55 | finally: 56 | await asp.__aexit__() 57 | return Result.success(message='') 58 | 59 | 60 | @dashboard_api.route('/login/confirm', methods=['POST']) 61 | async def login_confirm(): 62 | site_alias = json.loads(request.get_data().decode('utf-8'))['name'] 63 | c.is_confirmed.put(site_alias) 64 | c.config['default']['community'][site_alias]['is_login'] = True 65 | return Result.success(message='登录成功') 66 | 67 | 68 | @dashboard_api.route('/post/list', methods=['GET']) 69 | async def post_list(): 70 | file_paths = [] 71 | for root, dirs, files_in_dir in os.walk(config['data']['posts']['path']): 72 | for file in files_in_dir: 73 | if not file.endswith('.md'): 74 | continue 75 | file_paths.append(os.path.join(root, file)) 76 | files = [get_file_name_without_ext(str(file_path)) 77 | for file_path in file_paths] 78 | data = [{'name': name, 'path': path} 79 | for name, path in zip(files, file_paths)] 80 | return Result.success(data=data) 81 | 82 | 83 | @dashboard_api.route('/post/delete', methods=['POST']) 84 | async def post_delete(): 85 | file_path = json.loads(request.get_data().decode('utf-8'))['path'] 86 | os.remove(file_path) 87 | return Result.success(message='删除成功') 88 | -------------------------------------------------------------------------------- /server/plugins.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import os 3 | from flask import jsonify, Blueprint, request 4 | import webview 5 | from werkzeug.utils import secure_filename 6 | from common import constant 7 | from common.result import Result 8 | from utils.load import get_path 9 | from utils.plugins import get_plugins, plugin_install, plugin_uninstall 10 | plugins_api = Blueprint('plugins_api', __name__, url_prefix='/api/plugins') 11 | main_window = constant.main_window 12 | 13 | 14 | @plugins_api.route('', methods=['GET']) 15 | def get(): 16 | plugins = get_plugins() 17 | return Result.success(message='获取成功', data=plugins) 18 | 19 | 20 | @plugins_api.route('/uninstall', methods=['POST']) 21 | def uninstall(): 22 | name = request.get_json().get('name') 23 | plugin_uninstall(name) 24 | return Result.success(message='卸载成功') 25 | 26 | 27 | @plugins_api.route('/install', methods=['GET']) 28 | def install(): 29 | file_types = ('Py file (*.py)',) 30 | result = main_window.create_file_dialog( 31 | webview.OPEN_DIALOG, allow_multiple=False, file_types=file_types 32 | ) 33 | if not result: 34 | return Result.error(message='未选择文件') 35 | file_path = result[0] 36 | ret = plugin_install(file_path) 37 | return Result.success(data=result, message='安装成功') if ret else Result.error(message='安装失败') 38 | -------------------------------------------------------------------------------- /server/post.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import json 3 | import os.path 4 | 5 | import nest_asyncio 6 | from flask import Blueprint, request 7 | import webview 8 | 9 | from common.apis import PostArguments 10 | from common.constant import config 11 | from common.core import ProcessCore 12 | from common.handler import handle_global_exception 13 | from common.result import Result 14 | from common import constant as c 15 | from utils.file import get_path 16 | 17 | post_api = Blueprint('post_api', __name__, url_prefix='/api/post') 18 | main_window = c.main_window 19 | 20 | 21 | @post_api.route('/choose') 22 | def chooses_post(): 23 | file_types = ('Document files (*.md;*.html;*.docx)',) 24 | result = main_window.create_file_dialog( 25 | webview.OPEN_DIALOG, allow_multiple=False, file_types=file_types 26 | ) 27 | return Result.success(data=result, message='选择成功') 28 | 29 | 30 | @post_api.route('/choose/cover') 31 | def chooses_cover(): 32 | file_types = ('Image files (*.png;*.jpg;*.jpeg;*.gif)',) 33 | result = main_window.create_file_dialog( 34 | webview.OPEN_DIALOG, allow_multiple=False, file_types=file_types 35 | ) 36 | return Result.success(data=result, message='选择成功') 37 | 38 | 39 | @post_api.route('/save/file', methods=['POST']) 40 | def save_file(): 41 | data = json.loads(request.get_data().decode('utf-8')) 42 | if not data.get('content') or not data.get('title'): 43 | return Result.error(message='标题或者内容不能为空') 44 | file_path = os.path.join( 45 | get_path(config['data']['posts']['path']), data['title']+'.'+data['type']) 46 | # 创建文件夹 47 | if not os.path.exists(os.path.dirname(file_path)): 48 | os.makedirs(os.path.dirname(file_path)) 49 | with open(file_path, 'w', encoding='utf-8') as f: 50 | # 找到所有Localhost:54188的链接替换为真实链接 51 | content = data['content'].replace( 52 | c.APP_URL+'/temp', config['data']['temp']['path']) 53 | f.write(content) 54 | return Result.success(message='保存成功', data={'path': file_path}) 55 | 56 | 57 | @post_api.route('/upload', methods=['POST']) 58 | def upload_post(): 59 | data = json.loads(request.get_data().decode('utf-8')) 60 | data: PostArguments 61 | # 允许嵌套协程 62 | process_core = None 63 | try: 64 | nest_asyncio.apply() 65 | # 初始化 66 | process_core = ProcessCore( 67 | args=data 68 | ) 69 | # 处理数据 70 | return Result.success( 71 | [one_res for one_res in process_core.results.data], 72 | process_core.results.message 73 | ) 74 | except BaseException as e: 75 | if process_core and process_core.results.data is not None and len(process_core.results.data) > 0: 76 | print(e) 77 | return Result.success( 78 | [one_res for one_res in process_core.results.data], 79 | process_core.results.message 80 | ) 81 | if 'True' == str(config['app']['debug']): 82 | raise e 83 | else: 84 | ret = handle_global_exception(e) 85 | return Result.build(ret['code'], ret['data'], ret['message']) 86 | 87 | 88 | @post_api.route('/extract', methods=['POST']) 89 | def extract_post(): 90 | from snownlp import SnowNLP 91 | data = json.loads(request.get_data().decode('utf-8')) 92 | file = data.get('file') 93 | if not os.path.exists(file): 94 | return Result.error(message='文件不存在') 95 | with open(file, 'r', encoding='utf-8') as f: 96 | content = f.read() 97 | tags_num = data.get('tags_num', 3) 98 | if not content: 99 | return Result.error(message='内容不能为空') 100 | s = SnowNLP(content) 101 | tags = s.keywords(tags_num) 102 | digest = s.summary(1) 103 | return Result.success(data={ 104 | 'digest': digest, 105 | 'tags': tags 106 | }, message='提取成功') 107 | -------------------------------------------------------------------------------- /server/setting.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import json 3 | import os 4 | 5 | import yaml 6 | from flask import Blueprint, request 7 | from yaml.dumper import SafeDumper 8 | from common import constant as c 9 | from common.result import Result 10 | from utils.file import get_root_path, load_yaml 11 | 12 | setting_api = Blueprint('setting_api', __name__, url_prefix='/api/setting') 13 | 14 | 15 | @setting_api.route('/read', methods=['GET']) 16 | def read(): 17 | return Result.success(c.config, '读取配置成功') 18 | 19 | 20 | @setting_api.route('/save', methods=['POST']) 21 | def save(): 22 | # 转化相对的字符串为布尔值,否则后续读取会逻辑错误 23 | data = json.loads(request.get_data().decode('utf-8')) 24 | data['app']['debug'] = str( 25 | data['app']['debug']).strip("'").lower() == 'true' 26 | data['app']['installed'] = str( 27 | data['app']['installed']).strip("'").lower() == 'true' 28 | data['default']['headless'] = str( 29 | data['default']['headless']).strip("'").lower() == 'true' 30 | data['default']['devtools'] = str( 31 | data['default']['devtools']).strip("'").lower() == 'true' 32 | data['default']['no_viewport'] = str( 33 | data['default']['no_viewport']).strip("'").lower() == 'true' 34 | with open(get_root_path() + '/config.yaml', 'w', encoding='utf-8') as file: 35 | yaml.dump(data, file, default_flow_style=False, encoding='utf-8', 36 | Dumper=SafeDumper, sort_keys=False, allow_unicode=True) 37 | c.config = load_yaml(os.path.join(get_root_path(), c.CONFIG_FILE_PATH)) 38 | return Result.success(None, '更新配置成功') 39 | -------------------------------------------------------------------------------- /server/static.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import os 3 | 4 | from flask import send_from_directory 5 | from common import constant as c 6 | from utils.load import get_path 7 | 8 | server_app = c.server_app 9 | 10 | 11 | @server_app.route('/temp/') 12 | def temp_file(path): 13 | """ 14 | 托管用户的临时图片文件 15 | """ 16 | if not os.path.exists(get_path(c.config['data']['temp']['path'])): 17 | os.makedirs(get_path(c.config['data']['temp']['path'])) 18 | return send_from_directory(get_path(c.config['data']['temp']['path']), path) 19 | 20 | 21 | @server_app.route('/') 22 | def static_file(path): 23 | """ 24 | 托管静态文件 25 | """ 26 | return send_from_directory(server_app.static_folder, path) 27 | 28 | 29 | @server_app.route('/') 30 | def index(): 31 | """ 32 | 定义首页路由 33 | """ 34 | return send_from_directory(server_app.static_folder, 'index.html') 35 | -------------------------------------------------------------------------------- /server/window.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from flask import jsonify, Blueprint, request 3 | from common import constant 4 | from common.result import Result 5 | from utils.load import get_path 6 | 7 | window_api = Blueprint('window_api', __name__, url_prefix='/api/window') 8 | main_window = constant.main_window 9 | 10 | 11 | @window_api.route('/minimize', methods=['GET']) 12 | def minimize(): 13 | main_window.minimize() 14 | return Result.success('Minimize window success!') 15 | 16 | 17 | @window_api.route('/close', methods=['GET']) 18 | def close(): 19 | main_window.destroy() 20 | return Result.success('Close window success!') 21 | 22 | 23 | @window_api.route('/maximize', methods=['GET']) 24 | def maximize(): 25 | main_window.maximize() 26 | return Result.success('Maximize window success!') 27 | 28 | 29 | @window_api.route('/restore', methods=['GET']) 30 | def restore(): 31 | main_window.restore() 32 | return Result.success('Restore window success!') 33 | -------------------------------------------------------------------------------- /server/write.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import json 3 | import os.path 4 | import re 5 | 6 | import webview 7 | from flask import Blueprint,request 8 | import shutil 9 | from common import constant as c 10 | from utils.file import get_file_name_without_ext 11 | from common.result import Result 12 | from utils.load import get_path 13 | 14 | write_api = Blueprint('write_api', __name__, url_prefix='/api/write') 15 | 16 | 17 | @write_api.route('image/select') 18 | def upload_image(): 19 | file_types = ('Image files (*.png;*.jpg;*.jpeg;*.gif)',) 20 | result = c.main_window.create_file_dialog( 21 | webview.OPEN_DIALOG, allow_multiple=True, file_types=file_types 22 | ) 23 | if not result: 24 | return Result.error(message='未选择文件') 25 | result = list(result) 26 | for index, file_path in enumerate(result): 27 | copy_path = get_path(c.config['data']['temp']['path'] + f'/{os.path.basename(file_path)}') 28 | shutil.copy(file_path, copy_path) 29 | result[index] = c.APP_URL+'/temp/'+os.path.basename(file_path) 30 | return Result.success(data=result, message='选择成功') 31 | 32 | 33 | @write_api.route('load', methods=['POST']) 34 | def load_post(): 35 | data = json.loads(request.get_data().decode('utf-8')) 36 | file_path = data.get('path') 37 | if not os.path.exists(file_path): 38 | return Result.error(message='文件不存在') 39 | with open(file_path, 'r', encoding='utf-8') as f: 40 | content = f.read() 41 | content = content.replace(c.config['data']['temp']['path'],c.APP_URL+'/temp') 42 | return Result.success(data={'title': get_file_name_without_ext(file_path), 'content': content}, message='加载成功') 43 | -------------------------------------------------------------------------------- /static/imgs/default-cover.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xiaofengsoft/postsync/63c05363b3a47194a80543a496129565f64e77f7/static/imgs/default-cover.png -------------------------------------------------------------------------------- /static/imgs/logo.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xiaofengsoft/postsync/63c05363b3a47194a80543a496129565f64e77f7/static/imgs/logo.ico -------------------------------------------------------------------------------- /static/imgs/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xiaofengsoft/postsync/63c05363b3a47194a80543a496129565f64e77f7/static/imgs/logo.png -------------------------------------------------------------------------------- /static/imgs/official-account.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xiaofengsoft/postsync/63c05363b3a47194a80543a496129565f64e77f7/static/imgs/official-account.jpg -------------------------------------------------------------------------------- /static/imgs/reward-wechat.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xiaofengsoft/postsync/63c05363b3a47194a80543a496129565f64e77f7/static/imgs/reward-wechat.jpg -------------------------------------------------------------------------------- /tests/assets/imgs/ad.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xiaofengsoft/postsync/63c05363b3a47194a80543a496129565f64e77f7/tests/assets/imgs/ad.png -------------------------------------------------------------------------------- /tests/assets/imgs/adguard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xiaofengsoft/postsync/63c05363b3a47194a80543a496129565f64e77f7/tests/assets/imgs/adguard.png -------------------------------------------------------------------------------- /tests/assets/imgs/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xiaofengsoft/postsync/63c05363b3a47194a80543a496129565f64e77f7/tests/assets/imgs/logo.png -------------------------------------------------------------------------------- /tests/assets/imgs/wp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xiaofengsoft/postsync/63c05363b3a47194a80543a496129565f64e77f7/tests/assets/imgs/wp.png -------------------------------------------------------------------------------- /tests/assets/jsons/test.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "John Doe", 3 | "age": 30, 4 | "city": "New York" 5 | } -------------------------------------------------------------------------------- /tests/assets/posts/PostSync介绍.docx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xiaofengsoft/postsync/63c05363b3a47194a80543a496129565f64e77f7/tests/assets/posts/PostSync介绍.docx -------------------------------------------------------------------------------- /tests/assets/posts/PostSync介绍.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 106 | 107 |

110 |

PostSync

111 |

促进技术文章发展

112 |

介绍

113 |

这是一个开源的同步文章的软件,你可以使用它来同步你的文章到多个平台。

114 |

使用

115 |

打开浏览器,登录各个平台的账号,掘金、CSDN、知乎、公众号、哔哩哔哩、博客园、个人WordPress 117 |

118 |

打开config.yaml文件,配置你的浏览器信息以及浏览器用户数据目录

119 |

运行命令行:bash .\PostSync.exe -h

120 |

输入命令即可使用

121 |

开发

配置debug

122 |

打开config.yaml文件,将app/debug设置为True

打包

123 |

pyinstaller PostSync.spec

124 |

接着拷贝config.yaml到dist/PostSync目录下,命令行运行PostSync.exe即可

125 |

注意事项

126 |

在使用前请确保已经登录各个平台的账号

127 |

使用标签分类等功能请确保您在相关平台上已经创建相应的标签分类

128 |

使用前请退出浏览器

129 |

功能

130 |

自动同步文章到掘金、CSDN、知乎、公众号、哔哩哔哩、博客园、个人WordPress平台并返回生成文章链接 132 |

133 |

支持多协程,异步上传文章

134 |

支持包含查找,大小写模糊匹配

135 |

支持md,html文件

136 |

支持自定义默认配置

137 |

支持命令行界面

138 |

自定义标签、分类、专栏、封面、摘要

139 |

优化任务

140 |

[ ] 记录失败日志

141 |

[ ] 具体异常具体处理

142 |

[ ] 具体栏目参数具体处理

143 |

[ ] 优化代码类型结构

144 |

[ ] 优化代码文档读取生成

145 |

[ ] 未填写参数不输入网站处理

146 |

[ ] 公众号直接发布

147 |

[ ] 连接已经打开的浏览器实例

148 |

[ ] 包含查找优化为近似查找

149 |

开发规范

150 |

entity包下的新增社区嘞应继承Community类

151 |

新增社区类的命令应为首字母大写其余字母全部小写

152 |

代码风格遵循PEP8规范

153 |

技术架构

154 |

pytest

155 |

requests

156 |

playwright

157 |

faker

158 |

pyyaml

159 |

markdown

160 |

beautifulsoup4

161 |

argparse

162 |

nest-asyncio

163 |

pyinstaller

164 |

customtkinter

165 | -------------------------------------------------------------------------------- /tests/assets/posts/PostSync介绍.md: -------------------------------------------------------------------------------- 1 | ![](D:\\Python\\Projects\\MyGitProjects\\PostSync\\tests\\assets\\posts\\imgs\\img0.png) 2 | 3 | ### PostSync 4 | 5 | 促进技术文章发展 6 | 7 | ### 介绍 8 | 9 | 这是一个开源的同步文章的软件,你可以使用它来同步你的文章到多个平台。 10 | 11 | ### 使用 12 | 13 | 打开浏览器,登录各个平台的账号,掘金、CSDN、知乎、公众号、哔哩哔哩、博客园 14 | 15 | 打开config.yaml文件,配置你的浏览器信息以及浏览器用户数据目录 16 | 17 | 运行命令行:bash .\PostSync.exe -h 18 | 19 | 输入命令即可使用 20 | 21 | ### 开发 22 | 23 | #### _配置debug_ 24 | 25 | 打开config.yaml文件,将app/debug设置为True 26 | 27 | #### _打包_ 28 | 29 | pyinstaller PostSync.spec 30 | 31 | 接着拷贝config.yaml到dist/PostSync目录下,命令行运行PostSync.exe即可 32 | 33 | ### 注意事项 34 | 35 | 在使用前请确保已经登录各个平台的账号 36 | 37 | 使用标签分类等功能请确保您在相关平台上已经创建相应的标签分类 38 | 39 | 使用前请退出浏览器 40 | 41 | ### 功能 42 | 43 | 自动同步文章到掘金、CSDN、知乎、公众号、哔哩哔哩、博客园平台并返回生成文章链接 44 | 45 | 支持多协程,异步上传文章 46 | 47 | 支持包含查找,大小写模糊匹配 48 | 49 | 支持md,html文件 50 | 51 | 支持自定义默认配置 52 | 53 | 支持命令行界面 54 | 55 | 自定义标签、分类、专栏、封面、摘要 56 | 57 | ### 优化任务 58 | 59 | [ ] 记录失败日志 60 | 61 | [ ] 具体异常具体处理 62 | 63 | [ ] 具体栏目参数具体处理 64 | 65 | [ ] 优化代码类型结构 66 | 67 | [ ] 优化代码文档读取生成 68 | 69 | [ ] 未填写参数不输入网站处理 70 | 71 | [ ] 公众号直接发布 72 | 73 | [ ] 连接已经打开的浏览器实例 74 | 75 | [ ] 包含查找优化为近似查找 76 | 77 | ### 开发规范 78 | 79 | entity包下的新增社区嘞应继承Community类 80 | 81 | 新增社区类的命令应为首字母大写其余字母全部小写 82 | 83 | 代码风格遵循PEP8规范 84 | 85 | ### 技术架构 86 | 87 | pytest 88 | 89 | requests 90 | 91 | playwright 92 | 93 | faker 94 | 95 | pyyaml 96 | 97 | markdown 98 | 99 | beautifulsoup4 100 | 101 | argparse 102 | 103 | nest-asyncio 104 | 105 | pyinstaller 106 | 107 | customtkinter 108 | -------------------------------------------------------------------------------- /tests/assets/posts/imgs/img0.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xiaofengsoft/postsync/63c05363b3a47194a80543a496129565f64e77f7/tests/assets/posts/imgs/img0.png -------------------------------------------------------------------------------- /tests/assets/posts/利用AdGuard屏蔽必应搜索中的CSDN内容.md: -------------------------------------------------------------------------------- 1 | ### 原因 2 | 3 | 众所周知,CSDN 搜索结果,内容复制需要魔法,有的还需要关注,啥都要钱,质量还特别差 4 | 5 | 虽然 百度和必应可以写参数直接去除CSDN 搜索结果,但每次都要写很麻烦 6 | 7 | ### 解决办法 8 | 9 | 安装AdGuard > 进入设置 > 用户过滤器页面 10 | 11 | ![image-20240918224231026](/tests/assets/imgs/ad.png) 12 | 13 | ![image-20240918223924261](/tests/assets/imgs/adguard.png) 14 | 15 | 输入以下内容 16 | 17 | ``` 18 | bing.com#?##b_results > li > div.b_tpcn > a > div.tptxt > div.tpmeta > div > cite:has-text(csdn):upward(6) 19 | ``` 20 | 21 | 保存即可 22 | 23 | ### 内容解释 24 | 25 | 这是AdGuard的过滤规则 26 | 27 | - `bing.com#?#`:这部分指定了规则适用的域名,即bing.com。`#?` 是一个通配符,表示任何查询参数都可以,所以这条规则适用于bing.com及其子页面。 28 | - `##`:这个符号告诉AdGuard隐藏匹配的元素。在CSS选择器前面加上`##`,AdGuard会将该元素从DOM中完全移除,而不是仅仅隐藏它。 29 | - `b_results`:这是Bing搜索结果页面上包含搜索结果的容器的类名。 30 | - `> li`:这表示选择`b_results`容器直接子元素中的`
  • `元素,通常每个`
  • `代表一个搜索结果。 31 | - `> div.b_tpcn`:这表示选择`
  • `元素的直接子元素中的`
    `,其类名为`b_tpcn`,这个`
    `通常包含搜索结果的标题和描述。 32 | - `> a > div.tptxt > div.tpmeta > div > cite` 33 | :这一连串的选择器进一步定位到包含搜索结果元数据的元素,如来源网站的名称。``标签通常用于显示搜索结果的来源。 34 | - `:has-text(csdn)`:这是一个伪类选择器,用于选择包含特定文本的元素。在这个例子中,它用于选择包含“csdn”文本的``元素。 35 | - `:upward(6)`:这是一个自定义选择器,用于选择包含特定文本的元素及其向上六级的祖先元素。这意味着不仅`` 36 | 元素会被隐藏,它的六级祖先元素也会被隐藏,从而将整个搜索结果项隐藏。 37 | 38 | ### 缺点 39 | 40 | 搜索结果中的搜索结果条数会减少 41 | -------------------------------------------------------------------------------- /tests/pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | # 命令行参数 3 | addopts = -s 4 | # 搜索文件名 5 | python_files = test_*.py 6 | # 搜索的类名 7 | python_classes = Test* 8 | #搜索的函数名 9 | python_functions = test_* -------------------------------------------------------------------------------- /tests/test_post.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import os 3 | import subprocess 4 | 5 | from utils.file import get_root_path 6 | import pytest 7 | 8 | 9 | def run_command(command): 10 | """运行命令并处理错误""" 11 | try: 12 | subprocess.run(command, shell=True, check=True, stderr=subprocess.PIPE) 13 | except subprocess.CalledProcessError as e: 14 | print(f"命令执行失败: {e.cmd}") 15 | print(f"错误信息: {e.stderr.decode()}") 16 | 17 | 18 | class TestPost: 19 | 20 | def setup_class(self): 21 | os.chdir(get_root_path()) 22 | 23 | @pytest.mark.parametrize("site , test_file", [('zhihu', "tests/assets/posts/PostSync介绍.md")]) 24 | def test_post_with_single_site(self, site, test_file): 25 | """ 26 | 单次测试各个网站的 27 | :param site: 28 | :return: 29 | """ 30 | run_command(f'python app.py -f {test_file} -s {site}') 31 | 32 | @pytest.mark.parametrize( 33 | 'file,category,columns,tags,sites,cover_img', 34 | [ 35 | ('tests/assets/posts/PostSync介绍.docx', 'Python', '解决问题', 'python adguard', 36 | 'bilibili', 'tests/assets/imgs/wp.png'), 37 | # (r'"C:\Users\xiaof\Desktop\module collections has no attribute Hashable PyDocx 库报错.md"', 'Python', '解决', 38 | # 'PyDocx Hash', 'cnblog', r'') 39 | ] 40 | ) 41 | def test_post_all_args(self, file, category, columns, tags, sites, cover_img): 42 | """ 43 | 单次测试所有参数的发布 44 | :return: 45 | """ 46 | order = f'python app.py -f {file} -s {sites} -ca { 47 | category} -co {cover_img} -cl {columns} -t {tags} ' 48 | print(order) 49 | run_command(order) 50 | 51 | def test_help(self): 52 | run_command('python app.py --help') 53 | -------------------------------------------------------------------------------- /tests/units/funcs/test_browser_func.py: -------------------------------------------------------------------------------- 1 | from utils.browser import find_browser_executable 2 | 3 | 4 | def test_find_browser_executable(): 5 | print(find_browser_executable()) 6 | -------------------------------------------------------------------------------- /tests/units/funcs/test_data_func.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import pytest 3 | from utils.file import get_path 4 | from utils.data import convert_md_img_path_to_abs_path 5 | 6 | 7 | @pytest.mark.parametrize("path", [ 8 | "tests/assets/posts/PostSync介绍.md" 9 | ]) 10 | def test_convert_md_img_path_to_abs_path(path: str): 11 | convert_md_img_path_to_abs_path(get_path(path)) -------------------------------------------------------------------------------- /tests/units/funcs/test_domain_func.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import pytest 3 | from utils.domain import get_domain 4 | 5 | 6 | @pytest.mark.parametrize("url , expected", [ 7 | ("https://www.google.com", "www.google.com"), 8 | ("https://google.com", "google.com"), 9 | ("https://yunyicloud.cn","yunyicloud.cn"), 10 | ]) 11 | def test_get_domain(url: str, expected: str): 12 | assert get_domain(url) == expected -------------------------------------------------------------------------------- /tests/units/funcs/test_file_func.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import pytest 3 | from utils.file import get_file_path_without_ext, convert_html_to_docx, convert_docx_to_html, convert_docx_to_md, \ 4 | get_path 5 | from utils.file import check_file_same_name_exists 6 | from utils.file import convert_md_to_html,convert_md_to_docx,convert_html_to_md 7 | 8 | 9 | @pytest.mark.parametrize("file_path", [ 10 | r"C:\Users\xiaof\Desktop\PlayWright检测用户登录保存Cookie.md" 11 | ]) 12 | def test_convert_md_to_html(file_path: str) -> None: 13 | convert_md_to_html(file_path) 14 | 15 | 16 | @pytest.mark.parametrize("file_path", [ 17 | r"C:\Users\xiaof\Desktop\PlayWright检测用户登录保存Cookie.md" 18 | ]) 19 | def test_convert_md_to_html(file_path: str) -> None: 20 | convert_md_to_html(file_path) 21 | 22 | 23 | @pytest.mark.parametrize("file_path", [ 24 | r"tests/assets/posts/PostSync介绍.md" 25 | ]) 26 | def test_convert_md_to_docx(file_path: str) -> None: 27 | convert_md_to_docx(get_path(file_path)) 28 | 29 | 30 | @pytest.mark.parametrize("file_path", [ 31 | r"test/assets/posts/PostSync介绍.html" 32 | ]) 33 | def test_convert_html_to_md(file_path: str) -> None: 34 | convert_html_to_md(file_path) 35 | 36 | 37 | @pytest.mark.parametrize("file_path", [ 38 | r"tests/assets/posts/PostSync介绍.html" 39 | ]) 40 | def test_convert_html_to_docx(file_path: str) -> None: 41 | convert_html_to_docx(get_path(file_path)) 42 | 43 | 44 | @pytest.mark.parametrize("file_path", [ 45 | # r"C:\Users\xiaof\Desktop\PlayWright检测用户登录保存Cookie.docx", 46 | r"tests/assets/posts/PostSync介绍.docx" 47 | ]) 48 | def test_convert_docx_to_html(file_path: str) -> None: 49 | convert_docx_to_html(file_path) 50 | 51 | 52 | @pytest.mark.parametrize("file_path", [ 53 | r"C:\Users\xiaof\Desktop\PlayWright检测用户登录保存Cookie.docx" 54 | ]) 55 | def test_convert_docx_to_md(file_path: str) -> None: 56 | convert_docx_to_md(file_path) 57 | 58 | 59 | @pytest.mark.parametrize("file_path, expected_result", [ 60 | (r"D:\Python\Projects\MyGitProjects\PostSync\Readme.md", r"D:\Python\Projects\MyGitProjects\PostSync\Readme"), 61 | (r"D:\Python\Projects\MyGitProjects\PostSync\Readme.en.md", r"D:\Python\Projects\MyGitProjects\PostSync\Readme.en"), 62 | (r"tests\data\test.txt", r"tests\data\test"), 63 | ]) 64 | def test_get_file_path_without_ext(file_path, expected_result): 65 | print("file_path:", file_path) 66 | print("expected_result:", expected_result) 67 | assert get_file_path_without_ext(file_path) == expected_result 68 | 69 | 70 | @pytest.mark.parametrize("file_path, expected_path, ext", [ 71 | (r"tests\assets\posts\PostSync介绍.md",r"tests\assets\posts\PostSync介绍.html",'html'), 72 | ]) 73 | def test_check_file_same_name_exists(file_path, expected_path, ext): 74 | print("file_path:", file_path) 75 | print("expected_path:", expected_path) 76 | print("ext:", ext) 77 | print(check_file_same_name_exists(file_path, ext)) 78 | assert check_file_same_name_exists(file_path, ext) 79 | -------------------------------------------------------------------------------- /tests/units/test_browser.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from urllib.parse import urlparse 3 | import pytest 4 | import requests 5 | from playwright.sync_api import sync_playwright 6 | from playwright.async_api import async_playwright 7 | from common.constant import config 8 | import asyncio 9 | 10 | @pytest.mark.parametrize("url", ["https://zhuanlan.zhihu.com/api/articles/drafts"]) 11 | def test_valid_url(url): 12 | result = urlparse(url) 13 | print() 14 | print(result) 15 | print(result.scheme) 16 | return all([result.scheme, result.netloc]) 17 | 18 | 19 | @pytest.mark.parametrize("url", 20 | ["https://zhuanlan.zhihu.com/api/articles/drafts" 21 | ]) 22 | def test_request_post(url): 23 | response = requests.post(url, data={ 24 | "content": "test content", 25 | "del_time": "0", 26 | "table_of_contents": "false" 27 | }, headers={ 28 | "accept": "application/json, text/plain, */*", 29 | "accept-encoding": "gzip, deflate, br", 30 | "accept-language": "zh-CN,zh;q=0.9,en;q=0.8", 31 | "cache-control": "no-cache", 32 | "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/79.0.3945.130 Safari/537.36", 33 | "credentials": "include", 34 | "content-type": "application/json", 35 | "x-requested-with": "fetch", 36 | "x-xsrftoken": "4c454b91-6f84-490b-96c7-1c1a73c0c762", 37 | "x-zst-81": "3_2.0aR_sne90QR2VMhnyT6Sm2JLBkhnun820XM20cL_1kwxYUqwT16P0EiUZST2x-LOmwhp1tD_I-JOfgGXTzJO1ADRZ0cHsTJXII820Eer0c4nVDJH8zGCBADwMuukRe8tKIAtqS_L1VufXQ6P0mRPCyDQMovNYEgSCPRP0E4rZUrN9DDom3hnynAUMnAVPF_PhaueTFTLOLqOsDGtynqCqoexL6LVfTgg1svXB609fSMtfNUgCzcLMEULOtgwpYbeTVeHGjq9MkUemEDoVgUCfJiCLZcOfFvxYbDgm0JVf8vSM6vSqfT2L_hVqe8x8Nh30M6NKOJOm8UpLrbLy-JNCLBe0bipm0bXf88LMOgg8cePs0CL_dUY0Og_zoBSLbHxYnreqjCVYN92XcwCMpCSBBwSLfUHBNCwOwwOqBbrqkH3KOBLfzDXO0U90oipstC3mYuYMeqr9BrOKAvwB_JXOyhx0ywOskhNOp7c0Q7OLQ8ws" 38 | }) 39 | print(response.text) 40 | pass 41 | 42 | 43 | def test_persisted_cookies(): 44 | """ 45 | 测试知乎的上传内容功能 46 | :return: 47 | """ 48 | with sync_playwright() as p: 49 | browser = p.chromium.launch_persistent_context( 50 | channel=config['default']['browser'], 51 | headless=False, 52 | user_data_dir=config['data']['user']['dir'], 53 | no_viewport=True, 54 | args=['--start-maximized'], 55 | devtools=True 56 | ) 57 | page = browser.new_page() 58 | page.goto("https://zhuanlan.zhihu.com/write") 59 | resp = page.request.fetch( 60 | "https://zhuanlan.zhihu.com/api/articles/drafts", 61 | method="POST", 62 | data={ 63 | "content": "test content66666666666666", 64 | "del_time": "0", 65 | "table_of_contents": "false" 66 | }, 67 | headers={ 68 | "credentials": "include", 69 | "content-type": "application/json", 70 | "x-requested-with": "fetch", 71 | "x-xsrftoken": "4c454b91-6f84-490b-96c7-1c1a73c0c762", 72 | "x-zst-81": "3_2.0aR_sne90QR2VMhnyT6Sm2JLBkhnun820XM20cL_1kwxYUqwT16P0EiUZST2x-LOmwhp1tD_I-JOfgGXTzJO1ADRZ0cHsTJXII820Eer0c4nVDJH8zGCBADwMuukRe8tKIAtqS_L1VufXQ6P0mRPCyDQMovNYEgSCPRP0E4rZUrN9DDom3hnynAUMnAVPF_PhaueTFTLOLqOsDGtynqCqoexL6LVfTgg1svXB609fSMtfNUgCzcLMEULOtgwpYbeTVeHGjq9MkUemEDoVgUCfJiCLZcOfFvxYbDgm0JVf8vSM6vSqfT2L_hVqe8x8Nh30M6NKOJOm8UpLrbLy-JNCLBe0bipm0bXf88LMOgg8cePs0CL_dUY0Og_zoBSLbHxYnreqjCVYN92XcwCMpCSBBwSLfUHBNCwOwwOqBbrqkH3KOBLfzDXO0U90oipstC3mYuYMeqr9BrOKAvwB_JXOyhx0ywOskhNOp7c0Q7OLQ8ws" 73 | }, 74 | ignore_https_errors=True 75 | ) 76 | print(resp.body()) 77 | 78 | 79 | async def test_persisted_cookies_async(browser,ap): 80 | """ 81 | 测试知乎的上传请求功能 82 | :return: 83 | """ 84 | page = await browser.new_page() 85 | await page.goto("https://zhuanlan.zhihu.com/write") 86 | resp = await page.request.fetch( 87 | "https://zhuanlan.zhihu.com/api/articles/drafts", 88 | method="POST", 89 | data={ 90 | "content": "test content66666666666666", 91 | "del_time": "0", 92 | "table_of_contents": "false" 93 | }, 94 | headers={ 95 | "credentials": "include", 96 | "content-type": "application/json", 97 | "x-requested-with": "fetch", 98 | "x-xsrftoken": "4c454b91-6f84-490b-96c7-1c1a73c0c762", 99 | "x-zst-81": "3_2.0aR_sne90QR2VMhnyT6Sm2JLBkhnun820XM20cL_1kwxYUqwT16P0EiUZST2x-LOmwhp1tD_I-JOfgGXTzJO1ADRZ0cHsTJXII820Eer0c4nVDJH8zGCBADwMuukRe8tKIAtqS_L1VufXQ6P0mRPCyDQMovNYEgSCPRP0E4rZUrN9DDom3hnynAUMnAVPF_PhaueTFTLOLqOsDGtynqCqoexL6LVfTgg1svXB609fSMtfNUgCzcLMEULOtgwpYbeTVeHGjq9MkUemEDoVgUCfJiCLZcOfFvxYbDgm0JVf8vSM6vSqfT2L_hVqe8x8Nh30M6NKOJOm8UpLrbLy-JNCLBe0bipm0bXf88LMOgg8cePs0CL_dUY0Og_zoBSLbHxYnreqjCVYN92XcwCMpCSBBwSLfUHBNCwOwwOqBbrqkH3KOBLfzDXO0U90oipstC3mYuYMeqr9BrOKAvwB_JXOyhx0ywOskhNOp7c0Q7OLQ8ws" 100 | }, 101 | ignore_https_errors=True 102 | ) 103 | print(await resp.body()) 104 | await ap.__aexit__(None, None, None) 105 | 106 | @pytest.mark.asyncio 107 | async def test_persisted_much_async(): 108 | tasks = [] 109 | ap = async_playwright() 110 | p = await ap.start() 111 | browser = await p.chromium.launch_persistent_context( 112 | channel=config['default']['browser'], 113 | headless=False, 114 | user_data_dir=config['data']['user']['dir'], 115 | no_viewport=True, 116 | args=['--start-maximized'], 117 | devtools=True 118 | ) 119 | tasks.append(test_persisted_cookies_async(browser,ap)) 120 | await asyncio.gather(*tasks) 121 | 122 | -------------------------------------------------------------------------------- /tests/units/test_config.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from common.config import config 3 | from common.func import analyse_var 4 | 5 | def test_config(): 6 | analyse_var(config) 7 | 8 | 9 | def test_default(): 10 | analyse_var(config['default']) 11 | 12 | 13 | def test_default_tags(): 14 | analyse_var(config['default']['tags']) -------------------------------------------------------------------------------- /tests/units/test_dir.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import os 3 | 4 | import pytest 5 | 6 | 7 | @pytest.mark.parametrize("startpath", [ 8 | (r"D:\Python"), 9 | ]) 10 | def test_dir(startpath): 11 | """ 12 | This function is used to test the directory structure of the given path. 13 | :param startpath: 14 | :return: 15 | """ 16 | for root, dirs, files in os.walk(startpath): 17 | level = root.replace(startpath, '').count(os.sep) 18 | indent = ' ' * 4 * (level) 19 | print(f"{indent}{os.path.basename(root)}/") 20 | subindent = ' ' * 4 * (level + 1) 21 | for f in files: 22 | print(f"{subindent}{f}") 23 | -------------------------------------------------------------------------------- /tests/units/test_documents.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import pytest 3 | from utils.file import get_abs_path 4 | 5 | 6 | @pytest.mark.parametrize('md_file_path , dst_file_path', [ 7 | (r"C:\Users\xiaof\Desktop\PlayWright检测用户登录保存Cookie.md", 8 | r"C:\Users\xiaof\Desktop\PlayWright检测用户登录保存Cookie.html") 9 | ]) 10 | def test_md(md_file_path, dst_file_path): 11 | """ 12 | 测试markdown转html 13 | :return: 14 | """ 15 | import markdown 16 | from markdown.extensions.codehilite import CodeHiliteExtension 17 | from markdown.extensions.fenced_code import FencedCodeExtension 18 | md = markdown.Markdown(extensions=[FencedCodeExtension(), CodeHiliteExtension()]) 19 | md.convertFile(md_file_path, dst_file_path, "utf-8") 20 | 21 | 22 | def test_bs_html(): 23 | """ 24 | 测试bs4的html解析,转换图片链接 25 | :return: 26 | """ 27 | from bs4 import BeautifulSoup 28 | with (open(get_abs_path('tests/os/readme.html'), 'r', encoding='utf-8') as f): 29 | soup = BeautifulSoup(f.read(), 'html.parser') 30 | img_tags = soup.find_all("img") 31 | for img in img_tags: 32 | img['src'] = 'aaa.png' 33 | with open(get_abs_path('tests/os/readme_new.html'), 'w', encoding='utf-8') as f: 34 | f.write(str(soup)) 35 | 36 | 37 | def test_yaml_load(): 38 | from utils.file import load_yaml 39 | from utils.file import get_root_path 40 | print() 41 | print(load_yaml(get_root_path() + '/config.yaml')) 42 | -------------------------------------------------------------------------------- /tests/units/test_error.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from common.error import FileNotSupportedError 3 | 4 | 5 | def test_file_not_supported_error(): 6 | raise FileNotSupportedError('.pdf') 7 | -------------------------------------------------------------------------------- /tests/units/test_window.py: -------------------------------------------------------------------------------- 1 | import ctypes 2 | from ctypes import wintypes 3 | 4 | # 定义回调函数的类型 5 | EnumWindowsProc = ctypes.WINFUNCTYPE( 6 | ctypes.c_bool, wintypes.HWND, wintypes.LPARAM 7 | ) 8 | 9 | # 获取Windows API中所需函数的引用 10 | user32 = ctypes.windll.user32 11 | kernel32 = ctypes.windll.kernel32 12 | windows = [] 13 | 14 | 15 | def test_list_all_windows(): 16 | def foreach_window(hwnd, lParam): 17 | """回调函数:列出窗口句柄和标题""" 18 | length = user32.GetWindowTextLengthW(hwnd) # 获取窗口标题长度 19 | if length > 0: # 忽略无标题的窗口 20 | title = ctypes.create_unicode_buffer(length + 1) 21 | user32.GetWindowTextW(hwnd, title, length + 1) # 获取窗口标题 22 | windows.append((hwnd, title.value)) # 保存句柄和标题 23 | return True # 返回True以继续枚举下一个窗口 24 | """列出所有窗口句柄和标题""" 25 | windows.clear() # 清空窗口列表 26 | user32.EnumWindows(EnumWindowsProc(foreach_window), 0) # 开始枚举 27 | 28 | # 打印所有窗口句柄和标题 29 | for hwnd, title in windows: 30 | print(f"句柄: {hwnd}, 标题: {title}") 31 | 32 | 33 | def test_window(): 34 | hwnd = user32.FindWindowW(None, 'ChatGPT - Google Chrome') 35 | if hwnd != 0: 36 | user32.ShowWindow(hwnd, 0) # 0 表示隐藏窗口 37 | print("窗口已隐藏") 38 | 39 | 40 | def test_show_window(): 41 | hwnd = user32.FindWindowW(None, 'ChatGPT - Google Chrome') 42 | if hwnd != 0: 43 | user32.ShowWindow(hwnd, 1) # 1 表示显示窗口 44 | print("窗口已显示") -------------------------------------------------------------------------------- /tests/units/ui/test_drag.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import pytest 3 | import webview 4 | 5 | 6 | def test_drag_and_drop(): 7 | html = """ 8 | 9 | 18 | 19 | 20 |
    Drag me!
    21 |
    22 | 23 | """ 24 | window = webview.create_window( 25 | 'API example', 26 | html=html, 27 | frameless=True, 28 | easy_drag=False, 29 | ) 30 | webview.start(debug=True) 31 | -------------------------------------------------------------------------------- /tests/units/ui/test_file.py: -------------------------------------------------------------------------------- 1 | """ 2 | 测试打开文件对话框 3 | 并获取选择的文件路径 4 | 从而实现前端发起信号,后端处理对话框选择的文件并返回 5 | """ 6 | import webview 7 | 8 | 9 | def open_file(window: webview.Window): 10 | file_path = window.create_file_dialog(webview.OPEN_DIALOG) 11 | print("Selected file:", file_path) 12 | if file_path: 13 | # 这里可以处理选中的文件 14 | pass 15 | 16 | 17 | def test_file_dialog(): 18 | window = webview.create_window('File Dialog Example') 19 | webview.start(open_file, window) 20 | -------------------------------------------------------------------------------- /tests/units/ui/test_menu.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import webview 3 | import webview.menu as wm 4 | 5 | 6 | def change_active_window_content(): 7 | active_window = webview.active_window() 8 | if active_window: 9 | active_window.load_html('

    You changed this window!

    ') 10 | 11 | 12 | def click_me(): 13 | active_window = webview.active_window() 14 | if active_window: 15 | active_window.load_html('

    You clicked me!

    ') 16 | 17 | 18 | def do_nothing(): 19 | pass 20 | 21 | 22 | def say_this_is_window_2(): 23 | active_window = webview.active_window() 24 | if active_window: 25 | active_window.load_html('

    This is window 2

    ') 26 | 27 | 28 | def open_file_dialog(): 29 | active_window = webview.active_window() 30 | active_window.create_file_dialog(webview.SAVE_DIALOG, directory='/', save_filename='test.file') 31 | 32 | 33 | def test_menu(): 34 | window_1 = webview.create_window( 35 | 'Application Menu Example', 'https://pywebview.flowrl.com/hello' 36 | ) 37 | window_2 = webview.create_window( 38 | 'Another Window', html='

    Another window to test application menu

    ' 39 | ) 40 | 41 | menu_items = [ 42 | wm.Menu( 43 | 'Test Menu', 44 | [ 45 | wm.MenuAction('Change Active Window Content', change_active_window_content), 46 | wm.MenuSeparator(), 47 | wm.Menu( 48 | 'Random', 49 | [ 50 | wm.MenuAction('Click Me', click_me), 51 | wm.MenuAction('File Dialog', open_file_dialog), 52 | ], 53 | ), 54 | ], 55 | ), 56 | wm.Menu('Nothing Here', [wm.MenuAction('This will do nothing', do_nothing)]), 57 | ] 58 | 59 | webview.start(menu=menu_items) 60 | -------------------------------------------------------------------------------- /tests/units/ui/test_storage.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import webview 3 | from webview.errors import JavascriptException 4 | 5 | from utils.load import get_path 6 | 7 | 8 | def test_storage_js(): 9 | def evaluate_js(window): 10 | result = window.evaluate_js( 11 | r""" 12 | localStorage.setItem('test', 'test') 13 | localStorage.getItem('test') 14 | """ 15 | ) 16 | print(result) 17 | window = webview.create_window( 18 | 'Run custom JavaScript', 19 | 20 | url='https://kimi.moonshot.cn/') 21 | webview.start(evaluate_js, window,gui="edgechromium") 22 | 23 | 24 | def test_storage_dir(): 25 | window = webview.create_window( 26 | 'Storage Directory', 27 | url='https://kimi.moonshot.cn/' 28 | ), 29 | 30 | webview.start(debug=True,private_mode=False,gui="edgechromium",storage_path=get_path('data/webview')) 31 | -------------------------------------------------------------------------------- /tests/whole/test_async.py: -------------------------------------------------------------------------------- 1 | from playwright.async_api import async_playwright 2 | from typing import Dict, List 3 | import asyncio 4 | import json 5 | 6 | 7 | class AsyncPageGroupManager: 8 | def __init__(self): 9 | self.page_groups: Dict[str, List] = {} 10 | self.playwright = None 11 | self.browser = None 12 | self.context = None 13 | 14 | async def create_page_group(self, group_id: str): 15 | if group_id in self.page_groups: 16 | raise ValueError(f"Page group with id '{ 17 | group_id}' already exists.") 18 | self.page_groups[group_id] = [] 19 | 20 | async def add_page_to_group(self, group_id: str, page): 21 | if group_id not in self.page_groups: 22 | raise ValueError(f"Page group with id '{ 23 | group_id}' does not exist.") 24 | self.page_groups[group_id].append(page) 25 | 26 | async def get_pages_in_group(self, group_id: str) -> List: 27 | return self.page_groups.get(group_id, []) 28 | 29 | async def close_page_group(self, group_id: str): 30 | if group_id in self.page_groups: 31 | for page in self.page_groups[group_id]: 32 | if not page.is_closed(): # 检查页面是否已关闭 33 | await page.close() 34 | del self.page_groups[group_id] 35 | 36 | async def close_all(self): 37 | for group_id in list(self.page_groups.keys()): 38 | await self.close_page_group(group_id) 39 | if self.context: 40 | await self.context.close() 41 | if self.browser: 42 | await self.browser.close() 43 | if self.playwright: 44 | await self.playwright.stop() 45 | 46 | 47 | # 全局共享的页面组管理器实例 48 | global_manager = AsyncPageGroupManager() 49 | 50 | 51 | async def open_pages(): 52 | # 初始化 Playwright 资源 53 | global_manager.playwright = await async_playwright().start() 54 | global_manager.browser = await global_manager.playwright.chromium.launch(headless=False) 55 | global_manager.context = await global_manager.browser.new_context() 56 | 57 | # 创建页面组并添加页面 58 | await global_manager.create_page_group("group1") 59 | page1 = await global_manager.context.new_page() 60 | await global_manager.add_page_to_group("group1", page1) 61 | await page1.goto("https://example.com", wait_until="load") # 确保页面加载完成 62 | print(f"Opened page: {await page1.title()}") 63 | 64 | # 模拟设置一些 cookies 或本地存储 65 | await page1.evaluate("() => localStorage.setItem('key', 'value')") 66 | await page1.context.add_cookies([{ 67 | "name": "test_cookie", 68 | "value": "12345", 69 | "url": "https://example.com" 70 | }]) 71 | print("Added cookies and localStorage to page1") 72 | 73 | 74 | async def save_context_state(): 75 | # 等待一段时间,确保页面已经打开并设置了 cookies/localStorage 76 | await asyncio.sleep(5) 77 | 78 | # 保存上下文的状态(cookies 和 localStorage) 79 | if global_manager.context: 80 | storage_state = await global_manager.context.storage_state(path="state.json") 81 | print("Context state saved to state.json") 82 | print(json.dumps(storage_state, indent=2)) # 打印保存的状态 83 | 84 | # 关闭页面组和 Playwright 资源 85 | await global_manager.close_page_group("group1") 86 | await global_manager.close_all() 87 | 88 | 89 | async def main(): 90 | # 并行执行打开页面和保存上下文状态的任务 91 | open_task = asyncio.create_task(open_pages()) 92 | save_task = asyncio.create_task(save_context_state()) 93 | 94 | # 等待两个任务完成 95 | await asyncio.gather(open_task, save_task) 96 | 97 | # 运行异步代码 98 | asyncio.run(main()) 99 | -------------------------------------------------------------------------------- /ui/.eslintrc.js: -------------------------------------------------------------------------------- 1 | 2 | module.exports = { 3 | root: true, 4 | env: { 5 | node: true, 6 | }, 7 | extends: [ 8 | 'plugin:vue/vue3-essential', 9 | 'eslint:recommended', 10 | '@vue/typescript/recommended', 11 | ], 12 | parserOptions: { 13 | ecmaVersion: 2020, 14 | }, 15 | rules: { 16 | 'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off', 17 | 'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off', 18 | }, 19 | }; -------------------------------------------------------------------------------- /ui/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ -------------------------------------------------------------------------------- /ui/.npmignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | 4 | # build files 5 | es/ 6 | lib/ 7 | dist/ 8 | typings/ 9 | 10 | _site 11 | package 12 | tmp* 13 | temp* 14 | coverage 15 | test-report.html 16 | .idea/ 17 | yarn-error.log 18 | *.zip 19 | .history 20 | .stylelintcache 21 | yarn.lock 22 | package-lock.json 23 | pnpm-lock.yaml 24 | -------------------------------------------------------------------------------- /ui/README.md: -------------------------------------------------------------------------------- 1 |

    2 | 3 |

    4 |

    5 | 6 | TDesign Logo 7 | 8 |

    9 | 10 |

    11 | node compatility 12 | 13 | License 14 | 15 |

    16 | 17 | ## 项目简介 18 | 19 | `tdesign-vue-next` 是一个 TDesign 适配桌面端的组件库,适合在 vue3.x 技术栈项目中使用。 20 | 21 | ## 开发 22 | 23 | ### 安装依赖 24 | 25 | ```bash 26 | npm install 27 | ``` 28 | 29 | ### 启动项目 30 | 31 | ```bash 32 | npm run dev 33 | ``` 34 | 35 | ## 构建 36 | 37 | ### 构建正式环境 38 | 39 | ```bash 40 | npm run build 41 | ``` 42 | 43 | ## 开源协议 44 | 45 | TDesign 遵循 [MIT 协议](https://github.com/Tencent/tdesign-starter-cli/blob/develop/LICENSE)。 46 | -------------------------------------------------------------------------------- /ui/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | PostSync 9 | 10 | 11 | 12 |
    13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /ui/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ui", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "vue-tsc --noEmit && vite build", 9 | "preview": "vite preview", 10 | "prepare": "node -e \"if(require('fs').existsSync('.git')){process.exit(1)}\" || is-ci || husky install" 11 | }, 12 | "dependencies": { 13 | "axios": "^1.7.7", 14 | "md-editor-v3": "^4.20.3", 15 | "pinia": "^2.3.0", 16 | "tdesign-icons-vue-next": "^0.2.6", 17 | "tdesign-vue-next": "latest", 18 | "vite-svg-loader": "^5.1.0", 19 | "vue": "^3.4.21", 20 | "vue-router": "^4.4.5" 21 | }, 22 | "devDependencies": { 23 | "@types/node": "^22.7.4", 24 | "@vitejs/plugin-vue": "^5.0.4", 25 | "typescript": "^5.4.4", 26 | "vite": "^5.2.8", 27 | "vue-tsc": "^2.0.10" 28 | }, 29 | "description": "Base on tdesign-starter-cli" 30 | } 31 | -------------------------------------------------------------------------------- /ui/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xiaofengsoft/postsync/63c05363b3a47194a80543a496129565f64e77f7/ui/public/favicon.ico -------------------------------------------------------------------------------- /ui/public/imgs/logo-landscape.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xiaofengsoft/postsync/63c05363b3a47194a80543a496129565f64e77f7/ui/public/imgs/logo-landscape.png -------------------------------------------------------------------------------- /ui/src/App.vue: -------------------------------------------------------------------------------- 1 | 4 | 37 | -------------------------------------------------------------------------------- /ui/src/apis/dashboard.ts: -------------------------------------------------------------------------------- 1 | import request from "../utils/request"; 2 | 3 | export const checkLogin = (is_force: boolean = false) => { 4 | if (is_force) { 5 | return request.get("dashboard/login/check?force=true"); 6 | } 7 | return request.get("dashboard/login/check"); 8 | }; 9 | export const loginOnce = (name: string) => { 10 | return request.post("dashboard/login/once", { name }); 11 | }; 12 | export const getPostList = () => { 13 | return request.get("dashboard/post/list"); 14 | }; 15 | export const deletePostFile = (path: string) => { 16 | return request.post("dashboard/post/delete", { path }); 17 | }; 18 | 19 | export const resetLogin = (name: string) => { 20 | return request.post("dashboard/login/reset", { name }); 21 | }; 22 | 23 | export const confirmLogin = (name: string) => { 24 | return request.post("dashboard/login/confirm", { name }); 25 | } 26 | 27 | 28 | export default { 29 | checkLogin, 30 | loginOnce, 31 | getPostList, 32 | deletePostFile, 33 | resetLogin, 34 | confirmLogin 35 | }; 36 | -------------------------------------------------------------------------------- /ui/src/apis/plugin.ts: -------------------------------------------------------------------------------- 1 | import request from "../utils/request"; 2 | 3 | export const getPlugins = () => { 4 | return request.get("plugins"); 5 | } 6 | 7 | export const uninstallPlugins = (name: string) => { 8 | return request.post(`plugins/uninstall`, { 9 | name: name 10 | }); 11 | } 12 | 13 | export const installPlugin = () => { 14 | return request.get('plugins/install'); 15 | } 16 | 17 | export default { 18 | getPlugins, 19 | uninstallPlugins, 20 | installPlugin 21 | }; -------------------------------------------------------------------------------- /ui/src/apis/post.ts: -------------------------------------------------------------------------------- 1 | import request from "../utils/request"; 2 | 3 | export const choosePost = () => { 4 | return request.get("/post/choose"); 5 | }; 6 | 7 | export const chooseCover = () => { 8 | return request.get("/post/choose/cover"); 9 | }; 10 | 11 | export const savePostFile = (data: { 12 | title: string; 13 | type: string; 14 | content: string; 15 | }) => { 16 | return request.post("/post/save/file", data); 17 | }; 18 | 19 | export const uploadPost = (data: { 20 | title: string; 21 | digest: string; 22 | tags: string[]; 23 | cover: string; 24 | category: string[]; 25 | topic: string; 26 | columns: string[]; 27 | file: string; 28 | sites: string[]; 29 | }) => { 30 | return request.post("/post/upload", data); 31 | }; 32 | 33 | export const extractPost = (file: string) => { 34 | return request.post("/post/extract", { 35 | file, 36 | tags_num: 3, 37 | }); 38 | }; 39 | 40 | export default { 41 | choosePost, 42 | chooseCover, 43 | uploadPost, 44 | savePostFile, 45 | extractPost, 46 | }; 47 | -------------------------------------------------------------------------------- /ui/src/apis/setting.ts: -------------------------------------------------------------------------------- 1 | import request from "../utils/request"; 2 | 3 | export const getSettings = () => { 4 | return request.get("/setting/read"); 5 | }; 6 | 7 | export const saveSettings = (data: any) => { 8 | return request.post("/setting/save", data); 9 | }; 10 | 11 | export default { 12 | getSettings, 13 | saveSettings, 14 | }; 15 | -------------------------------------------------------------------------------- /ui/src/apis/window.ts: -------------------------------------------------------------------------------- 1 | import request from "../utils/request"; 2 | 3 | export const minimizeWindow = () => { 4 | return request.get("/window/minimize"); 5 | }; 6 | 7 | export const maximizeWindow = () => { 8 | return request.get("/window/maximize"); 9 | }; 10 | 11 | export const restoreWindow = () => { 12 | return request.get("/window/restore"); 13 | }; 14 | 15 | export const closeWindow = () => { 16 | return request.get("/window/close"); 17 | }; 18 | 19 | export default { 20 | minimizeWindow, 21 | maximizeWindow, 22 | restoreWindow, 23 | closeWindow, 24 | }; 25 | -------------------------------------------------------------------------------- /ui/src/apis/write.ts: -------------------------------------------------------------------------------- 1 | import request from "../utils/request"; 2 | 3 | export const selectImage = () => { 4 | return request.get("write/image/select"); 5 | }; 6 | 7 | export const loadPostFile = (path: string) => { 8 | return request.post("write/load", { path }); 9 | }; 10 | 11 | export default { 12 | selectImage, 13 | loadPostFile, 14 | }; 15 | -------------------------------------------------------------------------------- /ui/src/assets/imgs/logo-landscape.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xiaofengsoft/postsync/63c05363b3a47194a80543a496129565f64e77f7/ui/src/assets/imgs/logo-landscape.png -------------------------------------------------------------------------------- /ui/src/assets/imgs/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xiaofengsoft/postsync/63c05363b3a47194a80543a496129565f64e77f7/ui/src/assets/imgs/logo.png -------------------------------------------------------------------------------- /ui/src/assets/style/base.css: -------------------------------------------------------------------------------- 1 | body, 2 | p, 3 | h1, 4 | h2, 5 | h3, 6 | h4, 7 | h5, 8 | h6, 9 | ul, 10 | ol, 11 | dl, 12 | li, 13 | dt, 14 | dd { 15 | /* 默认有边距,都要清除 */ 16 | margin: 0; 17 | padding: 0; 18 | /*字体设置*/ 19 | font-size: 14px; 20 | font-family: "Microsoft Yahei", sans-serif; 21 | /* color: #ccc; */ 22 | /* 去掉列表的原点 */ 23 | list-style: none; 24 | /* 默认鼠标 */ 25 | cursor: default; 26 | } 27 | 28 | /*可选*/ 29 | html, 30 | body { 31 | width: 100%; 32 | height: 100%; 33 | font-size: 100px !important; 34 | } 35 | 36 | /*行内块元素*/ 37 | input, 38 | img { 39 | margin: 0; 40 | padding: 0; 41 | border: 0 none; 42 | outline-style: none; 43 | vertical-align: bottom; 44 | } 45 | 46 | /*行内元素*/ 47 | a, 48 | a:active, 49 | a:visited { 50 | /*下划线和颜色*/ 51 | text-decoration: none; 52 | /* color: #ccc; */ 53 | color: var(--td-brand-color-2) 54 | } 55 | 56 | a:hover { 57 | color: var(--td-brand-color-8); 58 | } 59 | 60 | textarea { 61 | /* 边框清零 */ 62 | border: none; 63 | /* 轮廓线清零 */ 64 | outline: none; 65 | /* 防止文本域被随意拖拽 */ 66 | resize: none; 67 | } 68 | 69 | i { 70 | /*文字样式*/ 71 | font-style: normal; 72 | } 73 | 74 | table { 75 | /*边框合并*/ 76 | border-collapse: collapse; 77 | border-spacing: 0; 78 | } 79 | 80 | 81 | /* 使用伪元素清除浮动 */ 82 | .clearfix::before, 83 | .clearfix::after { 84 | content: ""; 85 | height: 0; 86 | line-height: 0; 87 | display: block; 88 | visibility: none; 89 | clear: both; 90 | } 91 | 92 | .clearfix { 93 | zoom: 1; 94 | } 95 | 96 | /* 版心*/ 97 | .w { 98 | width: 1883px; 99 | margin: 0 auto; 100 | } 101 | 102 | label { 103 | display: inline-block; 104 | cursor: pointer; 105 | } -------------------------------------------------------------------------------- /ui/src/assets/style/global.css: -------------------------------------------------------------------------------- 1 | .t-card { 2 | box-shadow: var(--td-shadow-1); 3 | } 4 | 5 | /* 隐藏垂直滚动条 */ 6 | ::-webkit-scrollbar { 7 | width: 0px; 8 | /* 针对垂直滚动条 */ 9 | height: 0px; 10 | /* 针对水平滚动条 */ 11 | } 12 | 13 | * { 14 | /* 隐藏滚动条 */ 15 | scrollbar-width: none; 16 | scrollbar-color: #fff #fff; 17 | /* 第一个颜色是滚动条颜色,第二个是轨道颜色 */ 18 | } -------------------------------------------------------------------------------- /ui/src/components/Editor.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 95 | 96 | 128 | 133 | -------------------------------------------------------------------------------- /ui/src/components/InputSetting.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 73 | -------------------------------------------------------------------------------- /ui/src/components/PostListItem.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 57 | 58 | 74 | -------------------------------------------------------------------------------- /ui/src/components/SiteStatusItem.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 70 | 71 | 89 | -------------------------------------------------------------------------------- /ui/src/directives/click-once.directive.ts: -------------------------------------------------------------------------------- 1 | import { Directive, DirectiveBinding } from "vue"; 2 | 3 | const clickOnceDirective: Directive = { 4 | mounted(el: HTMLElement, binding: DirectiveBinding) { 5 | let isDisabled = false; 6 | 7 | const clickHandler = (e: Event) => { 8 | if (isDisabled) { 9 | e.stopPropagation(); 10 | e.preventDefault(); 11 | return; 12 | } 13 | 14 | isDisabled = true; 15 | el.classList.add("t-is-disabled"); 16 | 17 | // 执行绑定的方法 18 | if (typeof binding.value === "function") { 19 | binding.value(); 20 | } 21 | 22 | // 3秒后移除禁用状态 23 | setTimeout(() => { 24 | isDisabled = false; 25 | el.classList.remove("t-is-disabled"); 26 | }, 3000); 27 | }; 28 | 29 | el.addEventListener("click", clickHandler); 30 | 31 | // 在组件卸载时移除事件监听器 32 | (el as any)._clickOnceCleanup = () => { 33 | el.removeEventListener("click", clickHandler); 34 | }; 35 | }, 36 | unmounted(el: HTMLElement) { 37 | if ((el as any)._clickOnceCleanup) { 38 | (el as any)._clickOnceCleanup(); 39 | } 40 | }, 41 | }; 42 | 43 | export default clickOnceDirective; 44 | -------------------------------------------------------------------------------- /ui/src/main.ts: -------------------------------------------------------------------------------- 1 | import { createApp } from "vue"; 2 | import TDesign from "tdesign-vue-next"; 3 | import "./assets/style/theme.css"; 4 | import "./assets/style/base.css"; 5 | import "./assets/style/global.css"; 6 | import Router from "./router/index"; 7 | import App from "./App.vue"; 8 | import { createPinia } from "pinia"; 9 | import clickOnceDirective from "./directives/click-once.directive"; 10 | 11 | const pinia = createPinia(); 12 | const app = createApp(App).use(TDesign).use(Router); 13 | app.use(pinia); 14 | app.directive("click-once", clickOnceDirective); 15 | app.mount("#app"); 16 | -------------------------------------------------------------------------------- /ui/src/router/index.ts: -------------------------------------------------------------------------------- 1 | import { createRouter, createWebHistory } from "vue-router"; 2 | 3 | const routes = [ 4 | { 5 | path: "/", 6 | name: "Layout", 7 | component: () => import("@/views/Layout.vue"), 8 | children: [ 9 | { 10 | path: "", 11 | name: "Home", 12 | redirect: "/dashboard", 13 | }, 14 | { 15 | path: "dashboard", 16 | name: "dashboard", 17 | component: () => import("@/views/Dashboard.vue"), 18 | }, 19 | { 20 | path: "write", 21 | name: "write", 22 | component: () => import("@/views/Write.vue"), 23 | }, 24 | { 25 | path: "upload", 26 | name: "upload", 27 | component: () => import("@/views/Upload.vue"), 28 | }, 29 | { 30 | path: "plugins", 31 | name: "plugins", 32 | component: () => import("@/views/Plugins.vue"), 33 | }, 34 | { 35 | path: "config", 36 | name: "config", 37 | component: () => import("@/views/Config.vue"), 38 | }, 39 | { 40 | path: "test", 41 | name: "test", 42 | component: () => import("@/views/Test.vue"), 43 | } 44 | ], 45 | }, 46 | ]; 47 | 48 | export default createRouter({ 49 | history: createWebHistory(), 50 | routes, 51 | }); 52 | -------------------------------------------------------------------------------- /ui/src/store/config.ts: -------------------------------------------------------------------------------- 1 | import { defineStore } from 'pinia'; 2 | import { Configuration } from '../types/config'; 3 | import SettingApi from '../apis/setting'; 4 | 5 | export const useConfigStore = defineStore('config', { 6 | state: () => ({ 7 | configurations: {} as Configuration, 8 | }), 9 | actions: { 10 | initConfig() { 11 | SettingApi.getSettings().then((res: any) => { 12 | this.configurations = res.data.data; 13 | }); 14 | } 15 | }, 16 | }); 17 | -------------------------------------------------------------------------------- /ui/src/store/site.ts: -------------------------------------------------------------------------------- 1 | import { defineStore } from 'pinia'; 2 | import { SiteStatus } from '../types/site'; 3 | 4 | export const useSiteStore = defineStore('site', { 5 | state: () => ({ 6 | siteStatuses: [] as SiteStatus[], 7 | }), 8 | actions: { 9 | isEmpty() { 10 | return this.siteStatuses.length === 0; 11 | }, 12 | addSiteStatus(siteStatus: SiteStatus) { 13 | this.siteStatuses.push(siteStatus); 14 | }, 15 | updateSiteStatus(id: string, siteStatus: 0 | 1) { 16 | this.siteStatuses = this.siteStatuses.map((item: SiteStatus) => 17 | item.id === id ? { ...item, status: siteStatus } : item 18 | ); 19 | }, 20 | deleteSiteStatus(id: string) { 21 | this.siteStatuses = this.siteStatuses.filter( 22 | (item: SiteStatus) => item.id !== id 23 | ); 24 | } 25 | }, 26 | }); 27 | -------------------------------------------------------------------------------- /ui/src/types/config.d.ts: -------------------------------------------------------------------------------- 1 | export interface Configuration { 2 | [key: string]: string | number | boolean | Configuration; 3 | } 4 | 5 | -------------------------------------------------------------------------------- /ui/src/types/post.d.ts: -------------------------------------------------------------------------------- 1 | export interface Post { 2 | name: string; 3 | path: string; 4 | } -------------------------------------------------------------------------------- /ui/src/types/site.d.ts: -------------------------------------------------------------------------------- 1 | // 0 代表 未登录 2 | // 1 代表 已登录 3 | export interface SiteStatus { 4 | id: string 5 | name: string 6 | status: 0 | 1 7 | } -------------------------------------------------------------------------------- /ui/src/utils/helper.ts: -------------------------------------------------------------------------------- 1 | const isObject = (obj: any) => { 2 | return obj && typeof obj === "object" && !Array.isArray(obj); 3 | }; 4 | 5 | export default { 6 | isObject, 7 | }; 8 | -------------------------------------------------------------------------------- /ui/src/utils/request.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | 3 | const instance = axios.create({ 4 | baseURL: "/api", 5 | headers: { 6 | "Content-Type": "application/json", 7 | }, 8 | timeout: 1000000, 9 | }); 10 | 11 | export default instance; 12 | -------------------------------------------------------------------------------- /ui/src/views/Config.vue: -------------------------------------------------------------------------------- 1 | 41 | 42 | 61 | -------------------------------------------------------------------------------- /ui/src/views/Dashboard.vue: -------------------------------------------------------------------------------- 1 | 34 | 35 | 84 | 85 | 111 | -------------------------------------------------------------------------------- /ui/src/views/Layout.vue: -------------------------------------------------------------------------------- 1 | 63 | 64 | 133 | 134 | -------------------------------------------------------------------------------- /ui/src/views/Plugins.vue: -------------------------------------------------------------------------------- 1 | 64 | 65 | 98 | 99 | -------------------------------------------------------------------------------- /ui/src/views/Test.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | -------------------------------------------------------------------------------- /ui/src/views/Upload.vue: -------------------------------------------------------------------------------- 1 | 98 | 99 | -------------------------------------------------------------------------------- /ui/src/views/Write.vue: -------------------------------------------------------------------------------- 1 | 63 | 64 | 76 | -------------------------------------------------------------------------------- /ui/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | //https://cn.vitejs.dev/guide/env-and-mode.html#env-files 4 | declare module '*.vue' { 5 | import type { DefineComponent } from 'vue' 6 | const component: DefineComponent<{}, {}, any> 7 | export default component 8 | } 9 | -------------------------------------------------------------------------------- /ui/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "useDefineForClassFields": true, 5 | "module": "ESNext", 6 | "moduleResolution": "Node", 7 | "strict": true, 8 | "jsx": "preserve", 9 | "sourceMap": true, 10 | "resolveJsonModule": true, 11 | "isolatedModules": true, 12 | "esModuleInterop": true, 13 | "lib": ["ESNext", "DOM"], 14 | "skipLibCheck": true 15 | }, 16 | "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"], 17 | "references": [{ "path": "./tsconfig.node.json" }] 18 | } 19 | -------------------------------------------------------------------------------- /ui/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "module": "ESNext", 5 | "moduleResolution": "Node", 6 | "allowSyntheticDefaultImports": true 7 | }, 8 | "include": ["vite.config.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /ui/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import vue from "@vitejs/plugin-vue"; 3 | import { resolve } from "path"; 4 | 5 | // https://vitejs.dev/config/ 6 | export default defineConfig({ 7 | plugins: [vue()], 8 | resolve: { 9 | alias: [ 10 | { 11 | find: "@", 12 | replacement: resolve(__dirname, "./src"), 13 | }, 14 | ], 15 | }, 16 | server: { 17 | host: "0.0.0.0", 18 | port: 3000, 19 | open: true, 20 | proxy: { 21 | "/api": { 22 | target: "http://localhost:54188", //目标地址,一般是指后台服务器地址 23 | changeOrigin: true, //是否跨域 24 | rewriteWsOrigin: true, //是否重写websocket请求地址 25 | rewrite(path) { 26 | return path.replace(/^\/api/, "/api"); //重写路径,将/api替换成空字符串 27 | }, 28 | }, 29 | }, 30 | }, 31 | }); 32 | -------------------------------------------------------------------------------- /utils/analysis.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import typing as t 3 | from functools import wraps 4 | import time 5 | 6 | 7 | def calculate_time(func): 8 | """ 9 | 计算函数执行时间的装饰器 10 | :param func: 11 | :return: 12 | """ 13 | 14 | @wraps(func) 15 | def wrapper(*args, **kwargs): 16 | start_time = time.time() # 记录函数开始执行的时间 17 | result = func(*args, **kwargs) # 执行函数 18 | end_time = time.time() # 记录函数执行结束的时间 19 | execution_time = end_time - start_time # 计算执行时间 20 | print(f"Function {func.__name__} took {execution_time} seconds to execute") 21 | return result 22 | 23 | return wrapper 24 | 25 | 26 | def analyse_var(var: t.Any, retract: t.Optional[int] = 0): 27 | """ 28 | 解析变量 29 | :param var: 30 | :param retract: 31 | :return: 32 | """ 33 | print(f"{' ' * retract}type: {type(var)}") 34 | print(f"{' ' * retract}var: {var}") 35 | if isinstance(var, (int, float, str, bool)): 36 | print(f"{' ' * retract}length: {len(str(var))}") 37 | elif isinstance(var, (list, tuple)): 38 | for i, item in enumerate(var): 39 | print(f"{' ' * retract}[{i}]") 40 | analyse_var(item, retract + 2) 41 | elif isinstance(var, dict): 42 | for key, value in var.items(): 43 | print(f"{' ' * retract}{key}:") 44 | analyse_var(value, retract + 2) -------------------------------------------------------------------------------- /utils/browser.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | 用来处理浏览器相关的操作 4 | """ 5 | import os 6 | import platform 7 | import typing as t 8 | from importlib import import_module 9 | from common.core import Community 10 | import yaml 11 | from playwright.async_api import async_playwright, Browser, BrowserContext, PlaywrightContextManager 12 | import nest_asyncio 13 | from yaml import Dumper 14 | 15 | import utils.file 16 | from common import constant as c 17 | from utils.file import get_path 18 | 19 | 20 | def find_browser_executable() -> t.Optional[t.List[tuple[str, str]]]: 21 | system = platform.system() 22 | # Chrome 可执行文件可能的位置 23 | chrome_paths = { 24 | 'Windows': [ 25 | r"C:\Program Files\Google\Chrome\Application\chrome.exe", 26 | r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe" 27 | ], 28 | 'Darwin': [ # macOS 29 | "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" 30 | ], 31 | 'Linux': [ 32 | "/usr/bin/google-chrome", 33 | "/usr/local/bin/google-chrome", 34 | ] 35 | } 36 | # Firefox 可执行文件可能的位置 37 | firefox_paths = { 38 | 'Windows': [ 39 | r"C:\Program Files\Mozilla Firefox\firefox.exe", 40 | r"C:\Program Files (x86)\Mozilla Firefox\firefox.exe" 41 | ], 42 | 'Darwin': [ # macOS 43 | "/Applications/Firefox.app/Contents/MacOS/firefox" 44 | ], 45 | 'Linux': [ 46 | "/usr/bin/firefox", 47 | "/usr/local/bin/firefox", 48 | ] 49 | } 50 | # Edge 可执行文件可能的位置 51 | edge_paths = { 52 | 'Windows': [ 53 | r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe", 54 | r"C:\Program Files\Microsoft\Edge\Application\msedge.exe" 55 | ], 56 | 'Darwin': [ # macOS 57 | "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge" 58 | ], 59 | 'Linux': [ 60 | "/usr/bin/microsoft-edge", 61 | "/usr/local/bin/microsoft-edge", 62 | ] 63 | } 64 | 65 | # 使用 shutil.which 查找路径 66 | 67 | def find_browser_in_paths(paths): 68 | for path in paths: 69 | if os.path.exists(path): 70 | return path 71 | return None 72 | 73 | # 查找浏览器 74 | browser_executable = { 75 | 'Chrome': find_browser_in_paths(chrome_paths.get(system, [])), 76 | 'Firefox': find_browser_in_paths(firefox_paths.get(system, [])), 77 | 'Edge': find_browser_in_paths(edge_paths.get(system, [])), 78 | } 79 | # 输出找到的浏览器路径 80 | ret = [] 81 | for browser, executable in browser_executable.items(): 82 | if executable: 83 | ret.append((browser, executable)) 84 | return ret if ret else None 85 | 86 | 87 | async def create_context(headless: bool = False, **kwargs) -> t.Tuple[ 88 | Browser, BrowserContext, PlaywrightContextManager]: 89 | """ 90 | 创建浏览器上下文 91 | :param headless: 92 | :param kwargs: 93 | :return: 94 | """ 95 | nest_asyncio.apply() 96 | asp = async_playwright() 97 | ap = await asp.start() 98 | if os.path.exists(c.config['data']['executable']['path']): 99 | executable = c.config['data']['executable']['path'].lower() 100 | else: 101 | executable = find_browser_executable()[0][1] 102 | # 写入配置文件 103 | with open(get_path('/config.yaml'), 'w', encoding=c.FILE_ENCODING) as file: 104 | yaml.dump( 105 | c.config, file, default_flow_style=False, 106 | encoding='utf-8', Dumper=Dumper, sort_keys=False, 107 | allow_unicode=True 108 | ) 109 | executable = utils.file.get_file_path_without_ext(executable) 110 | if 'chrome' in executable or 'chromium' in executable or 'edge' in executable: 111 | browser = await ap.chromium.launch( 112 | channel='msedge' if 'edge' in executable else 'chrome', 113 | headless=headless, 114 | args=['--start-maximized --disable-blink-features=AutomationControlled --disable-web-security ' 115 | '--disable-site-isolation-trials'], 116 | devtools=bool(c.config['default']['devtools']), 117 | timeout=int(c.config['default']['timeout']), 118 | **kwargs 119 | ) 120 | elif 'firefox' in executable: 121 | browser = await ap.firefox.launch( 122 | headless=headless, 123 | args=['--start-maximized --disable-blink-features=AutomationControlled'], 124 | devtools=bool(c.config['default']['devtools']), 125 | timeout=int(c.config['default']['timeout']), 126 | **kwargs 127 | ) 128 | viewport = c.config['default']['no_viewport'] if c.config['default']['no_viewport'] else { 129 | 'width': c.config['view']['width'], 'height': c.config['view']['height']} 130 | utils.file.make_file_or_dir( 131 | c.config['data']['storage']['path'], is_dir=False, func=lambda x: x.write('{ }')) 132 | context = await browser.new_context( 133 | storage_state=get_path(c.config['data']['storage']['path']), 134 | no_viewport=viewport, 135 | user_agent=c.config['default']['user_agent'], 136 | ) 137 | return browser, context, asp 138 | 139 | 140 | def get_community_instance(site: str, browser: Browser, context: BrowserContext) -> Community: 141 | """ 142 | 得到社区实例 143 | :param site: 144 | :param browser: 145 | :param context: 146 | :return: 147 | """ 148 | site_cls = import_module('entity.' + site.strip()) 149 | site_instance = getattr(site_cls, site.strip().capitalize()) 150 | site_instance = site_instance( 151 | browser=browser, 152 | context=context, 153 | ) 154 | c.site_instances[site] = site_instance 155 | return site_instance 156 | -------------------------------------------------------------------------------- /utils/data.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import json 3 | import re 4 | from common import constant as c 5 | from utils.helper import wait_random_time 6 | from utils.load import get_path 7 | import os 8 | from playwright.async_api import Page 9 | import base64 10 | from PIL import Image 11 | import io 12 | 13 | 14 | def get_storage_data(path: str) -> str: 15 | """ 16 | 获取存储数据 17 | :param path: 18 | :return: 19 | """ 20 | with open(path, 'r', encoding='utf-8') as file: 21 | data = json.load(file) 22 | return data 23 | 24 | 25 | def format_json_file(path: str) -> str: 26 | """ 27 | 格式化JSON文件 28 | 在每次写入JSON数据时,都调用此函数格式化JSON文件 29 | :param path: 30 | :return: 格式化后的JSON字符串 31 | """ 32 | path = get_path(path) 33 | with open(path, 'r+', encoding='utf-8') as file: 34 | data = json.load(file) 35 | json_str = json.dumps(data, indent=4, ensure_ascii=False) 36 | file.seek(0) 37 | file.write(json_str) 38 | file.truncate() 39 | return json_str 40 | 41 | 42 | def convert_html_content_to_md(html_content: str) -> str: 43 | """ 44 | 将HTML内容转换为Markdown 45 | :param html_content: 46 | :return: 47 | """ 48 | from tomd import Tomd 49 | markdown_content = Tomd(html_content).markdown 50 | return markdown_content 51 | 52 | 53 | def convert_html_img_path_to_abs_path(html_file_path: str, is_written: bool = True): 54 | """ 55 | 将HTML文件中图片的路径转换为绝对路径 56 | :param html_file_path: HTML文件路径 57 | :param is_written: 是否要写入文件还是作为值返回 58 | """ 59 | with open(html_file_path, 'r', encoding='utf-8') as f: 60 | html_content = f.read() 61 | from bs4 import BeautifulSoup 62 | soup = BeautifulSoup(html_content, 'html.parser') 63 | img_tags = soup.find_all('img') 64 | for img_tag in img_tags: 65 | img_src = img_tag.get('src') 66 | if not img_src.startswith('http'): 67 | img_tag['src'] = os.path.join( 68 | os.path.dirname(html_file_path), img_src) 69 | if is_written: 70 | with open(html_file_path, 'w', encoding='utf-8') as f: 71 | f.write(str(soup)) 72 | return str(soup) 73 | 74 | 75 | def convert_html_content_img_path_to_abs_path(html_content: str, html_dir: str): 76 | """ 77 | 将HTML文件中图片的路径转换为绝对路径 78 | :param html_content: 79 | :param html_dir: 80 | :return: 81 | """ 82 | from bs4 import BeautifulSoup 83 | soup = BeautifulSoup(html_content, 'html.parser') 84 | img_tags = soup.find_all('img') 85 | for img_tag in img_tags: 86 | img_src = img_tag.get('src') 87 | if not img_src.startswith('http'): 88 | img_tag['src'] = os.path.join(html_dir, img_src) 89 | img_tag['src'] = img_tag['src'].replace('\\', r'/') 90 | return str(soup) 91 | 92 | 93 | def convert_md_img_path_to_abs_path(md_file_path: str, is_written: bool = True): 94 | """ 95 | 将MD文件中图片的路径转换为绝对路径 96 | :param md_file_path: MD文件路径 97 | :param is_written: 是否要写入文件还是作为值返回 98 | """ 99 | md_content = convert_html_img_path_to_abs_path(md_file_path, is_written) 100 | image_pattern = re.compile(r'!\[.*?\]\((.*?)\)') 101 | img_paths = re.findall(image_pattern, md_content) 102 | for img_path in img_paths: 103 | if not img_path.startswith('http'): 104 | if not os.path.isabs(img_path): 105 | md_content = md_content.replace(img_path, os.path.join( 106 | os.path.dirname(md_file_path), img_path)) 107 | if is_written: 108 | with open(md_file_path, 'w', encoding=c.FILE_ENCODING) as f: 109 | f.write(md_content) 110 | return md_content 111 | 112 | 113 | async def insert_anti_detection_script(page: Page): 114 | """ 115 | 在页面中插入防检测脚本 116 | :param page: 117 | """ 118 | await page.evaluate("""() => { 119 | Object.defineProperty(navigator, 'webdriver', { 120 | get: () => undefined 121 | }); 122 | }""") 123 | with open(get_path('static/scripts/stealth.min.js'), 'r', encoding=c.FILE_ENCODING) as f: 124 | js = f.read() 125 | await page.add_init_script(js) 126 | 127 | 128 | def convert_base64_to_local_img(base64_content: str, img_path: str, img_format: str = "PNG"): 129 | """ 130 | 将HTML内容中的base64图片转换为本地图片 131 | :param base64_content: base64字符串 132 | :param img_path: 本地图片路径 133 | :param img_format: 图片格式 134 | """ 135 | # 将base64字符串解码成二进制数据 136 | image_data = base64.b64decode(base64_content) 137 | # 使用PIL库将二进制数据转换为图片 138 | image = Image.open(io.BytesIO(image_data)) 139 | # 保存图片到本地 140 | image.save(img_path, img_format) 141 | 142 | 143 | def convert_html_content_images_base64_to_local(html_content: str, img_dir_path: str, img_format: str = "PNG") -> str: 144 | """ 145 | 将HTML内容中的base64图片转换为本地图片 146 | 如果不包含图片则不转化直接返回原内容 147 | :param html_content: HTML内容 148 | :param img_dir_path: 本地图片保存路径 149 | :param img_format: 图片格式 150 | :return: 转换后的HTML内容 151 | """ 152 | # 将base64编码的图片转化为本地图片 153 | base64_strings = re.findall( 154 | r' { 177 | const element = document.querySelector(element_selector); 178 | element.innerHTML = html_content; 179 | } 180 | """, [element_selector, html_content]) 181 | 182 | 183 | async def insert_html_content_to_frame(page: Page, frame_selector: str, content_selector: str, html_content: str): 184 | """ 185 | 将HTML内容插入到Frame中 186 | :param page: Page对象 187 | :param frame_selector: Frame选择器 188 | :param content_selector: 内容选择器 189 | :param html_content: HTML内容 190 | :return: 191 | """ 192 | await page.wait_for_selector(frame_selector) 193 | await page.evaluate(""" 194 | ([frame_selector,content_selector,html_content]) => { 195 | const frameElement = document.querySelector(frame_selector); 196 | frameElement.contentDocument.querySelector(content_selector).innerHTML = html_content; 197 | } 198 | """, [frame_selector, content_selector, html_content]) 199 | 200 | 201 | async def scroll_to_element(page: Page, selector: str): 202 | while True: 203 | # 检查元素是否在视口中可见 204 | is_visible = await page.is_visible(selector) 205 | if is_visible: 206 | break 207 | # 如果不可见,执行滚动操作 208 | await page.evaluate('window.scrollBy(0, window.innerHeight / 2);') 209 | # 添加一个小的延迟,防止滚动速度过快 210 | wait_random_time() 211 | 212 | 213 | async def delete_blank_tags(page: Page, selector: str): 214 | """ 215 | 删除元素中的所有标签 216 | :param page: 217 | :param selector: 218 | :return: 219 | """ 220 | await page.evaluate(""" 221 | ([selector]) => { 222 | const tags = document.querySelectorAll(selector); 223 | tags.forEach(e => { 224 | if(e.innerHTML.trim() === '' && e.children.length <= 0){ 225 | e.remove(); 226 | } 227 | }); 228 | } 229 | """, [selector]) 230 | 231 | 232 | async def on_click_by_selector(page: Page, selector: str): 233 | """ 234 | 点击元素 235 | :param page: 236 | :param selector: 237 | :return: 238 | """ 239 | await page.evaluate(""" 240 | ([selector]) => { 241 | const element = document.querySelector(selector); 242 | element.click(); 243 | } 244 | """, [selector]) 245 | -------------------------------------------------------------------------------- /utils/device.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import ctypes 3 | 4 | 5 | def get_real_resolution(): 6 | """ 7 | 获取屏幕真实分辨率 8 | :return: 屏幕真实分辨率的宽和高 9 | """ 10 | user32 = ctypes.windll.user32 11 | gdi32 = ctypes.windll.gdi32 12 | dc = user32.GetDC(None) 13 | 14 | width = gdi32.GetDeviceCaps(dc, 118) # 原始分辨率的宽度 15 | height = gdi32.GetDeviceCaps(dc, 117) # 原始分辨率的高度 16 | return width, height 17 | 18 | 19 | def get_scale_resolution(): 20 | """ 21 | 获取屏幕缩放分辨率 22 | :return: 屏幕缩放分辨率的宽和高 23 | """ 24 | user32 = ctypes.windll.user32 25 | gdi32 = ctypes.windll.gdi32 26 | dc = user32.GetDC(None) 27 | widthScale = gdi32.GetDeviceCaps(dc, 8) # 分辨率缩放后的宽度 28 | heightScale = gdi32.GetDeviceCaps(dc, 10) # 分辨率缩放后的高度 29 | return widthScale, heightScale 30 | -------------------------------------------------------------------------------- /utils/domain.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | def get_domain(url: str): 4 | """ 5 | Extract domain name from a URL. 6 | """ 7 | from urllib.parse import urlparse 8 | parsed_url = urlparse(url) 9 | return parsed_url.netloc 10 | 11 | 12 | def join_url_paths(base_url: str, paths: list) -> str: 13 | """ 14 | Join base URL and paths to create a new URL. 15 | """ 16 | from urllib.parse import urljoin 17 | return urljoin(base_url, '/'.join(paths)) -------------------------------------------------------------------------------- /utils/file.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from utils.data import convert_html_content_images_base64_to_local 3 | import typing as t 4 | from pydocx import PyDocX 5 | from common.constant import * 6 | import os 7 | from utils.data import convert_md_img_path_to_abs_path, convert_html_img_path_to_abs_path 8 | 9 | 10 | def get_path(path: str) -> str: 11 | """ 12 | 给出可用路径 13 | 如果参数是项目相对路径,则返回项目根目录路径加上参数路径 14 | 如果参数是绝对路径,则直接返回参数路径 15 | :param path: 路径 16 | :return: 可用路径 17 | """ 18 | if os.path.isabs(path): 19 | return os.path.normpath(path) 20 | else: 21 | return os.path.normpath(os.path.join(get_root_path(), path)) 22 | 23 | 24 | def replace_file_ext(path: str, new_ext: str) -> str: 25 | """ 26 | 替换文件扩展名 27 | """ 28 | if path.endswith('.' + new_ext): 29 | return path 30 | return os.path.splitext(path)[0] + '.' + new_ext 31 | 32 | 33 | def get_abs_path(path: str) -> str: 34 | """ 35 | 获取一个文件的绝对路径 36 | """ 37 | return os.path.join(get_root_path(), path) 38 | 39 | 40 | def get_root_path() -> str: 41 | """ 42 | 获取项目根目录路径 43 | """ 44 | return os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 45 | 46 | 47 | def get_file_name_without_ext(path: str) -> str: 48 | """ 49 | 获取文件名(不含扩展名) 50 | """ 51 | return os.path.splitext(os.path.basename(path))[0] 52 | 53 | 54 | def get_file_ext(path: str) -> str: 55 | """ 56 | 获取文件扩展名 57 | """ 58 | return os.path.splitext(path)[1] 59 | 60 | 61 | def get_file_name_ext(path: str) -> (str, str): 62 | """ 63 | 获取文件名和扩展名(去除.号) 64 | """ 65 | name_ext = os.path.splitext(os.path.basename(get_path(path))) 66 | return name_ext[0], name_ext[1][1:] 67 | 68 | 69 | def get_file_dir(path: str) -> str: 70 | """ 71 | 获取文件所在目录 72 | """ 73 | return os.path.dirname(path) 74 | 75 | 76 | def load_yaml(path: str) -> dict: 77 | """ 78 | 加载YAML文件 79 | """ 80 | import yaml 81 | with open(path, 'r', encoding=FILE_ENCODING) as f: 82 | yaml_data = yaml.load(stream=f, Loader=yaml.FullLoader) 83 | return yaml_data 84 | 85 | 86 | def make_file_or_dir(path: str, is_dir: bool = False, func: t.Callable[[t.IO], None] = None) -> bool: 87 | """ 88 | 创建文件或目录,如果不存在则创建,如果存在则不做任何操作 89 | :param path: 文件或目录路径 90 | :param is_dir: 是否为目录 91 | :param func: 创建文件后需要执行的函数 92 | :return: 是否已经存在 93 | """ 94 | if is_dir: 95 | if os.path.exists(path): 96 | return True 97 | else: 98 | os.makedirs(path) 99 | else: 100 | if os.path.exists(path): 101 | return True 102 | else: 103 | os.makedirs(os.path.dirname(path), exist_ok=True) 104 | with open(path, 'w', encoding=FILE_ENCODING) as f: 105 | f.write('') 106 | if func: 107 | func(f) 108 | return False 109 | 110 | 111 | def convert_md_to_html(md_file_path: str, html_file_path: str = None, is_written: bool = True) -> str: 112 | """ 113 | 将Markdown文件转换为HTML 114 | :param md_file_path: Markdown文件路径 115 | :param html_file_path: HTML文件路径,默认为Markdown文件路径 116 | :param is_written: 是否写入文件 117 | :return: HTML文件路径或者HTML内容 118 | """ 119 | if html_file_path is None: 120 | dst_file_path = check_file_same_name_exists( 121 | md_file_path, HTML_EXTENSIONS) 122 | if dst_file_path: 123 | return dst_file_path 124 | else: 125 | dst_file_path = get_path(replace_file_ext(md_file_path, 'html')) 126 | else: 127 | dst_file_path = html_file_path 128 | if is_written: 129 | md_html_parser.convertFile(md_file_path, dst_file_path, FILE_ENCODING) 130 | return dst_file_path 131 | else: 132 | with open(md_file_path, 'r', encoding=FILE_ENCODING) as f: 133 | md_content = f.read() 134 | html_content = md_html_parser.convert(md_content) 135 | return html_content 136 | 137 | 138 | def convert_md_content_to_html(md_content: str) -> str: 139 | """ 140 | 将Markdown内容转换为HTML 141 | :param md_content: Markdown内容 142 | :return: HTML内容 143 | """ 144 | return md_html_parser.convert(md_content) 145 | 146 | 147 | def clean_image_url(url: str) -> str: 148 | """ 149 | 清理图片URL,移除查询参数 150 | :param url: 图片URL 151 | :return: 清理后的URL 152 | """ 153 | return url.split('?')[0] if '?' in url else url 154 | 155 | 156 | def convert_html_to_docx(html_file_path: str, docx_file_path: t.Optional[str] = None) -> str: 157 | """ 158 | 将HTML文件转换为DOCX 159 | :param html_file_path: HTML文件路径 160 | :param docx_file_path: DOCX文件路径,默认为HTML文件路径 161 | :return: DOCX文件路径 162 | """ 163 | if docx_file_path is None: 164 | dst_file_path = check_file_same_name_exists( 165 | html_file_path, DOC_EXTENSIONS) 166 | if dst_file_path: 167 | return dst_file_path 168 | else: 169 | dst_file_path = replace_file_ext(html_file_path, 'docx') 170 | else: 171 | dst_file_path = docx_file_path 172 | 173 | # 读取HTML内容 174 | with open(html_file_path, 'r', encoding=FILE_ENCODING) as f: 175 | html_content = f.read() 176 | 177 | # 清理图片URL中的查询参数 178 | import re 179 | html_content = re.sub(r'(]*src=[\'"])([^\'"]+)([\'"][^>]*>)', 180 | lambda m: m.group( 181 | 1) + clean_image_url(m.group(2)) + m.group(3), 182 | html_content) 183 | 184 | # 写回处理后的HTML 185 | with open(html_file_path, 'w', encoding=FILE_ENCODING) as f: 186 | f.write(html_content) 187 | 188 | # 转换图片路径为绝对路径 189 | convert_html_img_path_to_abs_path(html_file_path) 190 | html_docx_parser.parse_html_file( 191 | html_file_path, get_file_path_without_ext(dst_file_path)) 192 | return dst_file_path 193 | 194 | 195 | def convert_md_to_docx(md_file_path: str, docx_file_path: str = None) -> str: 196 | """ 197 | 将Markdown文件转换为DOCX 198 | :param md_file_path: Markdown文件路径 199 | :param docx_file_path: DOCX文件路径,默认为Markdown文件路径 200 | :return: DOCX文件内容 201 | """ 202 | if docx_file_path is None: 203 | dst_file_path = check_file_same_name_exists( 204 | md_file_path, DOC_EXTENSIONS) 205 | if dst_file_path: 206 | return dst_file_path 207 | else: 208 | dst_file_path = get_path(replace_file_ext(md_file_path, 'docx')) 209 | else: 210 | dst_file_path = docx_file_path 211 | html_file_path = check_file_same_name_exists(md_file_path, HTML_EXTENSIONS) 212 | if not html_file_path: 213 | html_file_path = convert_md_to_html(md_file_path, docx_file_path) 214 | convert_md_img_path_to_abs_path(html_file_path) 215 | convert_md_to_html(md_file_path, html_file_path) 216 | convert_html_to_docx(html_file_path, docx_file_path) 217 | return dst_file_path 218 | 219 | 220 | def convert_docx_to_html(docx_file_path: str, html_file_path: str = None) -> str: 221 | """ 222 | 将DOCX文件转换为HTML 223 | DOCX文件转换为HTML后,文档中附带的图片将自动转换为本地图片 224 | :param docx_file_path: DOCX文件路径 225 | :param html_file_path: HTML文件路径,默认为DOCX文件路径 226 | :return: HTML文件路径 227 | """ 228 | if html_file_path is None: 229 | dst_file_path = check_file_same_name_exists( 230 | docx_file_path, HTML_EXTENSIONS) 231 | if dst_file_path: 232 | return dst_file_path 233 | else: 234 | dst_file_path = get_path(replace_file_ext(docx_file_path, 'html')) 235 | else: 236 | dst_file_path = html_file_path 237 | 238 | html_content = PyDocX.to_html(get_path(docx_file_path)) 239 | 240 | with open(dst_file_path, 'w', encoding=FILE_ENCODING) as f: 241 | convert_html_content_images_base64_to_local( 242 | html_content, os.path.dirname(dst_file_path)) 243 | f.write(html_content) 244 | return dst_file_path 245 | 246 | 247 | def convert_html_to_md(html_file_path: str, md_file_path: str = None) -> str: 248 | """ 249 | 将HTML文件转换为Markdown 250 | :param html_file_path: HTML文件路径 251 | :param md_file_path: Markdown文件路径,默认为HTML文件路径 252 | :return: Markdown内容 253 | """ 254 | if md_file_path is None: 255 | dst_file_path = check_file_same_name_exists( 256 | html_file_path, MD_EXTENSIONS) 257 | if dst_file_path: 258 | return dst_file_path 259 | else: 260 | dst_file_path = replace_file_ext(html_file_path, 'md') 261 | else: 262 | dst_file_path = md_file_path 263 | with open(html_file_path, 'r', encoding=FILE_ENCODING) as f: 264 | html_content = f.read() 265 | md_content = html_md_parser(html_content) 266 | with open(dst_file_path, 'w', encoding=FILE_ENCODING) as f: 267 | f.write(md_content) 268 | return dst_file_path 269 | 270 | 271 | def convert_docx_to_md(docx_file_path: str, md_file_path: str = None) -> str: 272 | """ 273 | 将DOCX文件转换为Markdown 274 | :param docx_file_path: DOCX文件路径 275 | :param md_file_path: Markdown文件路径,默认为DOCX文件路径 276 | :return: Markdown文件路径 277 | """ 278 | if md_file_path is None: 279 | dst_file_path = check_file_same_name_exists( 280 | docx_file_path, MD_EXTENSIONS) 281 | if dst_file_path: 282 | return dst_file_path 283 | else: 284 | dst_file_path = replace_file_ext(docx_file_path, 'md') 285 | else: 286 | dst_file_path = md_file_path 287 | html_file_path = check_file_same_name_exists( 288 | docx_file_path, HTML_EXTENSIONS) 289 | if not html_file_path: 290 | html_file_path = convert_docx_to_html(docx_file_path, md_file_path) 291 | convert_html_to_md(html_file_path, md_file_path) 292 | return dst_file_path 293 | 294 | 295 | def get_file_path_without_ext(path: str): 296 | """ 297 | 获取文件路径(不含扩展名) 298 | :param path: 文件路径 299 | :return: 300 | """ 301 | return os.path.splitext(str(path))[0] 302 | 303 | 304 | def check_file_same_name_exists(path: str, exts: t.Union[str, t.List[str]]) -> t.Union[str, bool]: 305 | """ 306 | 检查文件是否存在,如果相关扩展名有一个存在则返回该文件路径,否则返回False 307 | :param exts: 文件扩展名 308 | :param path: 文件路径 309 | :return: 310 | """ 311 | if isinstance(exts, str): 312 | exts = [exts] 313 | for ext in exts: 314 | real_path_with_ext = get_path( 315 | ".".join([get_file_path_without_ext(path), ext])) 316 | if os.path.exists(real_path_with_ext): 317 | return real_path_with_ext 318 | return False 319 | -------------------------------------------------------------------------------- /utils/helper.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import random 3 | import time 4 | 5 | 6 | def wait_random_time(begin_time: float = 0.2, end_time: float = 0.5): 7 | """ 8 | 等待随机时间 9 | :param begin_time: 开始时间 10 | :param end_time: 结束时间 11 | """ 12 | wait_time = random.uniform(begin_time, end_time) 13 | time.sleep(wait_time) 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /utils/load.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | 加载项目需要的工具库 4 | 此文件不可以依赖配置文件 5 | """ 6 | import os 7 | 8 | 9 | def load_yaml(path: str, encoding='utf-8') -> dict: 10 | """ 11 | 加载YAML文件 12 | """ 13 | import yaml 14 | with open(path, 'r', encoding=encoding) as f: 15 | yaml_data = yaml.safe_load(stream=f) 16 | return yaml_data 17 | 18 | 19 | def get_root_path() -> str: 20 | """ 21 | 获取项目根目录路径 22 | """ 23 | return os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 24 | 25 | 26 | def get_path(path: str) -> str: 27 | """ 28 | 给出可用路径 29 | 如果参数是项目相对路径,则返回项目根目录路径加上参数路径 30 | 如果参数是绝对路径,则直接返回参数路径 31 | :param path: 路径 32 | :return: 可用路径 33 | """ 34 | if os.path.isabs(path): 35 | return os.path.normpath(path) 36 | else: 37 | return os.path.normpath(os.path.join(get_root_path(), path)) -------------------------------------------------------------------------------- /utils/plugins.py: -------------------------------------------------------------------------------- 1 | from importlib import import_module 2 | import importlib 3 | import common.constant as c 4 | from common.core import Community 5 | from common.error import BrowserError 6 | import os 7 | import inspect 8 | from utils.load import get_path 9 | import ast 10 | from utils.file import get_file_name_without_ext 11 | import shutil 12 | 13 | from utils.storage import storage_config 14 | 15 | 16 | def is_first_class_inherits_community(file_path): 17 | """判断文件中的第一个类是否继承自 Community""" 18 | with open(file_path, "r", encoding="utf-8") as f: 19 | try: 20 | # 解析文件的 AST 21 | tree = ast.parse(f.read(), filename=file_path) 22 | except SyntaxError: 23 | print(f"Syntax error in file: {file_path}") 24 | return False 25 | 26 | # 遍历 AST 节点 27 | for node in ast.walk(tree): 28 | if isinstance(node, ast.ClassDef): # 找到第一个类定义 29 | # 检查基类列表中是否有 Community 30 | for base in node.bases: 31 | if isinstance(base, ast.Name) and base.id == "Community": 32 | return True 33 | return False # 第一个类不继承自 Community 34 | return False # 文件中没有类定义 35 | 36 | 37 | def get_plugins(): 38 | # 获取插件列表 39 | module_names = [] 40 | for root, dirs, files in os.walk(get_path('entity')): 41 | for file in files: 42 | if file.endswith(".py") and is_first_class_inherits_community(os.path.join(root, file)): 43 | module_name = get_file_name_without_ext(file) 44 | module = importlib.import_module(f"entity.{module_name}") 45 | community_class = next( 46 | (cls for name, cls in inspect.getmembers(module, inspect.isclass) 47 | if issubclass(cls, Community) and cls != Community), 48 | None 49 | ) 50 | desc = getattr(community_class, 'desc', '未知描述') 51 | name = getattr(community_class, 'site_name', '未知名称') 52 | module_names.append((module_name, { 53 | "desc": desc, 54 | "name": name 55 | })) 56 | return module_names 57 | 58 | 59 | def plugin_uninstall(plugin_name: str) -> bool: 60 | try: 61 | # 获取插件文件路径 62 | plugin_path = os.path.join(get_path('entity'), f"{plugin_name}.py") 63 | # 检查文件是否存在 64 | if os.path.exists(plugin_path): 65 | # 删除文件 66 | os.remove(plugin_path) 67 | # 删除配置 68 | del c.config['default']['community'][plugin_name] 69 | storage_config() 70 | return True 71 | return False 72 | except Exception as e: 73 | print(f"Error uninstalling plugin: {str(e)}") 74 | return False 75 | 76 | 77 | def plugin_install(file_path: str) -> bool: 78 | """ 79 | 安装插件到entity目录 80 | :param file_path: 插件文件临时路径 81 | :return: 是否安装成功 82 | """ 83 | try: 84 | # 检查文件是否为Python文件 85 | if not file_path.endswith('.py'): 86 | return False 87 | 88 | # 检查文件是否包含继承自Community的类 89 | if not is_first_class_inherits_community(file_path): 90 | return False 91 | 92 | # 获取目标路径 93 | target_path = os.path.join( 94 | get_path('entity'), os.path.basename(file_path)) 95 | 96 | # 复制文件到entity目录 97 | shutil.copy2(file_path, target_path) 98 | 99 | # 获取插件名称 100 | plugin_name = get_file_name_without_ext(target_path) 101 | 102 | # 获取插件模块第一个类中的 conf 字段 103 | module = import_module(f"entity.{plugin_name}") 104 | community_class = next( 105 | (cls for name, cls in inspect.getmembers(module, inspect.isclass) 106 | if issubclass(cls, Community) and cls != Community), 107 | None 108 | ) 109 | conf = getattr(community_class, 'conf', None) 110 | # 将 conf 字段引入 config.yaml 111 | c.config['default']['community'][plugin_name] = conf 112 | # 将desc字段引入config.yaml 113 | desc = getattr(community_class, 'desc', '未知描述') 114 | name = getattr(community_class, 'site_name', '未知名称') 115 | c.config['default']['community'][plugin_name]['desc'] = name 116 | c.config['default']['community'][plugin_name]['is_login'] = False 117 | storage_config() 118 | return True 119 | except Exception as e: 120 | print(f"Error installing plugin: {str(e)}") 121 | return False 122 | -------------------------------------------------------------------------------- /utils/storage.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from common import constant as c 3 | from playwright.async_api import Page 4 | import yaml 5 | from utils.load import get_root_path 6 | import os 7 | 8 | 9 | def storage_config(): 10 | with open(get_root_path() + '/config.yaml', 'w', encoding='utf-8') as file: 11 | yaml.dump(c.config, file, default_flow_style=False, encoding='utf-8', 12 | Dumper=yaml.SafeDumper, sort_keys=False, allow_unicode=True) 13 | 14 | 15 | async def get_page_local_storage(page: Page) -> dict: 16 | local_storage = await page.evaluate(""" 17 | () => { 18 | const items = {}; 19 | for (let i = 0; i < localStorage.length; i++) { 20 | const key = localStorage.key(i); 21 | items[key] = localStorage.getItem(key); 22 | } 23 | return items; 24 | } 25 | """) 26 | return local_storage 27 | 28 | 29 | def setup_wizard(): 30 | """ 31 | 安装向导 32 | """ 33 | import tkinter as tk 34 | from tkinter import messagebox, filedialog 35 | root = tk.Tk() 36 | root.withdraw() # 隐藏主窗口 37 | messagebox.showinfo("PostSync 安装向导", "欢迎使用PostSync!请选择数据存储目录。") 38 | storage_path = filedialog.askdirectory(title="选择数据存储目录") 39 | if not storage_path: 40 | messagebox.showerror("错误", "必须选择存储目录!") 41 | return False 42 | try: 43 | # 创建必要的目录结构 44 | os.makedirs(storage_path, exist_ok=True) 45 | # 更新配置 46 | c.config['app']['installed'] = True 47 | c.config['data']['path'] = storage_path 48 | c.config['data']['posts']['path'] = os.path.join(storage_path, 'posts') 49 | c.config['data']['storage']['path'] = os.path.join( 50 | storage_path, 'storage.json') 51 | c.config['data']['webview']['path'] = os.path.join( 52 | storage_path, 'webview') 53 | c.config['data']['temp']['path'] = os.path.join( 54 | storage_path, 'temp') 55 | c.config['data']['log']['path'] = os.path.join( 56 | storage_path, 'log') 57 | # 如果已经存在目录则不创建 58 | if not os.path.exists(c.config['data']['path']): 59 | # 创建目录 60 | os.makedirs(c.config['data']['posts']['path'], exist_ok=True) 61 | os.makedirs(c.config['data']['webview']['path'], exist_ok=True) 62 | os.makedirs(c.config['data']['temp']['path'], exist_ok=True) 63 | os.makedirs(c.config['data']['log']['path'], exist_ok=True) 64 | # 创建空的storage.json 65 | with open(c.config['data']['storage']['path'], 'w', encoding='utf-8') as file: 66 | file.write("{}") 67 | if c.config['data']['executable']['path'] is not None and not os.path.exists(c.config['data']['executable']['path']): 68 | # 选择浏览器 69 | messagebox.showinfo("选择浏览器", "请选择您的浏览器程序") 70 | browser_path = filedialog.askopenfilename( 71 | title="选择浏览器程序", filetypes=[("浏览器程序", "*.exe")]) 72 | if not browser_path: 73 | messagebox.showerror("错误", "必须选择浏览器程序!") 74 | c.config['data']['executable']['path'] = browser_path 75 | # 保存配置 76 | storage_config() 77 | messagebox.showinfo("安装完成", f"PostSync数据目录将会安装到:\n{storage_path}") 78 | return True 79 | except Exception as e: 80 | messagebox.showerror("安装失败", f"发生错误:{str(e)}") 81 | return False 82 | --------------------------------------------------------------------------------