Browse Source

Merge commit '8c140ef65488f577957bdc3fa9a279bfa783e110' into develop

liuyuqi-cnb 1 day ago
parent
commit
bc65cc61b0
9 changed files with 308 additions and 41 deletions
  1. 21 12
      .github/workflows/build-windows.yml
  2. 3 0
      .gitignore
  3. 1 1
      docker-compose.debug.yml
  4. 2 1
      docker-compose.yml
  5. 51 11
      gui.py
  6. 55 0
      gui.spec
  7. 55 0
      main.spec
  8. 8 2
      searchdomain/db.py
  9. 112 14
      searchdomain/searchdomain.py

+ 21 - 12
.github/workflows/build-windows.yml

@@ -14,7 +14,9 @@ on:
 jobs:
   build-windows:
     runs-on: windows-latest
-    
+
+    permissions:
+      actions: write
     steps:
     - name: Checkout code
       uses: actions/checkout@v4
@@ -30,27 +32,25 @@ jobs:
         pip install -r requirements.txt
         pip install pyinstaller
         
-    - name: Create data directory structure
-      run: |
-        if not exist "dist\data" mkdir dist\data
-        copy data\a.csv dist\data\
-        copy data\b.csv dist\data\
-        
     - name: Build GUI executable
+      shell: pwsh
       run: |
         pyinstaller --clean --noconfirm gui.spec
         
     - name: Build CLI executable
+      shell: pwsh
       run: |
         pyinstaller --clean --noconfirm main.spec
         
     - name: Create release package
+      shell: pwsh
       run: |
-        if not exist "release" mkdir release
-        copy dist\searchdomain_gui.exe release\
-        copy dist\searchdomain_cli.exe release\
-        copy data\a.csv release\data\
-        copy data\b.csv release\data
+        New-Item -ItemType Directory -Path "release" -Force | Out-Null
+        New-Item -ItemType Directory -Path "release\data" -Force | Out-Null
+        Copy-Item dist\searchdomain_gui.exe release\
+        Copy-Item dist\searchdomain_cli.exe release\
+        Copy-Item data\a.csv release\data\
+        Copy-Item data\b.csv release\data\
         
     - name: Upload artifacts
       uses: actions/upload-artifact@v4
@@ -67,3 +67,12 @@ jobs:
         prerelease: false
       env:
         GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+
+    - name: Remove old artifacts
+      uses: c-hive/gha-remove-artifacts@v1
+      with:
+        age: '3 days' # '<number> <unit>', e.g. 5 days, 2 years, 90 seconds, parsed by Moment.js
+        # Optional inputs
+        # skip-tags: true
+        skip-recent: 1

+ 3 - 0
.gitignore

@@ -32,6 +32,9 @@ MANIFEST
 #  before PyInstaller builds the exe, so as to inject date/other infos into it.
 *.manifest
 *.spec
+# Exception: Keep build configuration spec files
+!gui.spec
+!main.spec
 
 # Installer logs
 pip-log.txt

+ 1 - 1
docker-compose.debug.yml

@@ -2,7 +2,7 @@ version: '3.4'
 
 services:
   searchdomain:
-    image: searchdomain
+    image: jianboy/earchdomain:latest
     build:
       context: .
       dockerfile: ./Dockerfile

+ 2 - 1
docker-compose.yml

@@ -2,4 +2,5 @@ version: '3.4'
 
 services:
   searchdomain:
-    image: jianboy/earchdomain
+    image: jianboy/earchdomain:latest
+    

+ 51 - 11
gui.py

@@ -147,6 +147,9 @@ class SearchDomainGUI:
         self.search_button = ttk.Button(button_frame, text="开始搜索", command=self.start_search)
         self.search_button.pack(side=tk.LEFT, padx=5)
         
+        self.cancel_search_button = ttk.Button(button_frame, text="终止", command=self.cancel_search, state=tk.DISABLED)
+        self.cancel_search_button.pack(side=tk.LEFT, padx=5)
+        
         ttk.Button(button_frame, text="清空日志", command=self.clear_log).pack(side=tk.LEFT, padx=5)
         
     def browse_input_file(self):
@@ -291,50 +294,87 @@ class SearchDomainGUI:
             messagebox.showerror("错误", f"输入文件不存在: {input_file}")
             return
             
-        # 准备参数
+        # 准备输出文件路径
         if not os.path.isabs(output_file):
             output_file = os.path.join(self.app_path, output_file)
+        
+        # 确保输出目录存在
+        output_dir = os.path.dirname(output_file)
+        if output_dir and not os.path.exists(output_dir):
+            try:
+                os.makedirs(output_dir, exist_ok=True)
+            except Exception as e:
+                messagebox.showerror("错误", f"无法创建输出目录: {output_dir}\n{str(e)}")
+                return
             
+        # 准备参数
+        # app_path 应该设置为输出文件的目录,因为 saveRes 使用 app_path + output
         params = OrderedDict({
-            "input": os.path.basename(input_file),
-            "output": os.path.basename(output_file),
-            "app_path": os.path.dirname(input_file)
+            "input": input_file,  # 使用完整路径
+            "output": os.path.basename(output_file),  # 只使用文件名
+            "app_path": os.path.dirname(output_file)  # 输出文件的目录
         })
         
         # 在后台线程中运行
         self.is_running = True
         self.search_button.config(state=tk.DISABLED)
+        self.cancel_search_button.config(state=tk.NORMAL)
         self.status_var.set("正在搜索域名...")
         
         thread = threading.Thread(target=self._search_thread, args=(params, export_all))
         thread.daemon = True
         thread.start()
+    
+    def cancel_search(self):
+        """取消搜索任务"""
+        if self.search_domain_instance and self.is_running:
+            self.search_domain_instance.cancel()
+            self.log("正在终止搜索任务...")
+            self.status_var.set("正在终止...")
         
     def _search_thread(self, params, export_all):
         """搜索域名的后台线程"""
         try:
             self.log(f"开始搜索域名...")
             self.log(f"输入文件: {params['input']}")
+            self.log(f"输出目录: {params['app_path']}")
             self.log(f"输出文件: {params['output']}")
             self.log(f"导出所有: {export_all}")
             
-            searchdomain = SearchDomain(params=params, debug=True, export_all=export_all)
-            searchdomain.run()
+            # 传入日志回调函数,将搜索进度显示在运行日志中
+            self.search_domain_instance = SearchDomain(params=params, debug=True, export_all=export_all, log_callback=self.log)
+            self.search_domain_instance.run()
             
-            self.log("域名搜索完成!")
-            output_path = os.path.join(params["app_path"], params["output"])
-            self.log(f"结果已保存到: {output_path}")
-            messagebox.showinfo("成功", f"域名搜索完成!\n结果已保存到: {output_path}")
+            # 检查是否被取消
+            if self.search_domain_instance._cancelled:
+                self.log("搜索任务已取消")
+                self.root.after(0, lambda: messagebox.showinfo("提示", "搜索任务已取消"))
+            else:
+                self.log("域名搜索完成!")
+                output_path = os.path.join(params["app_path"], params["output"])
+                self.log(f"结果已保存到: {output_path}")
+                
+                # 验证文件是否真的存在
+                if os.path.exists(output_path):
+                    file_size = os.path.getsize(output_path)
+                    self.log(f"文件大小: {file_size} 字节")
+                    self.root.after(0, lambda: messagebox.showinfo("成功", f"域名搜索完成!\n结果已保存到: {output_path}\n文件大小: {file_size} 字节"))
+                else:
+                    error_msg = f"警告:输出文件不存在: {output_path}"
+                    self.log(error_msg)
+                    self.root.after(0, lambda: messagebox.showwarning("警告", error_msg))
             
         except Exception as e:
             error_msg = f"搜索域名时出错: {str(e)}"
             self.log(error_msg)
             import traceback
             self.log(traceback.format_exc())
-            messagebox.showerror("错误", error_msg)
+            self.root.after(0, lambda: messagebox.showerror("错误", error_msg))
         finally:
             self.is_running = False
+            self.search_domain_instance = None
             self.root.after(0, lambda: self.search_button.config(state=tk.NORMAL))
+            self.root.after(0, lambda: self.cancel_search_button.config(state=tk.DISABLED))
             self.root.after(0, lambda: self.status_var.set("就绪"))
 
 

+ 55 - 0
gui.spec

@@ -0,0 +1,55 @@
+# -*- mode: python ; coding: utf-8 -*-
+
+block_cipher = None
+
+import os
+import whois
+
+# 获取whois数据目录路径
+whois_path = os.path.dirname(whois.__file__)
+whois_data_path = os.path.join(whois_path, 'data')
+
+a = Analysis(
+    ['gui.py'],
+    pathex=[],
+    binaries=[],
+    datas=[
+        ('data/a.csv', 'data'),
+        ('data/b.csv', 'data'),
+        (whois_data_path, 'whois/data'),  # 包含whois数据目录
+    ],
+    hiddenimports=[],
+    hookspath=[],
+    hooksconfig={},
+    runtime_hooks=[],
+    excludes=[],
+    win_no_prefer_redirects=False,
+    win_private_assemblies=False,
+    cipher=block_cipher,
+    noarchive=False,
+)
+
+pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
+
+exe = EXE(
+    pyz,
+    a.scripts,
+    a.binaries,
+    a.zipfiles,
+    a.datas,
+    [],
+    name='searchdomain_gui',
+    debug=False,
+    bootloader_ignore_signals=False,
+    strip=False,
+    upx=True,
+    upx_exclude=[],
+    runtime_tmpdir=None,
+    console=False,
+    disable_windowed_traceback=False,
+    argv_emulation=False,
+    target_arch=None,
+    codesign_identity=None,
+    entitlements_file=None,
+    icon=None,
+)

+ 55 - 0
main.spec

@@ -0,0 +1,55 @@
+# -*- mode: python ; coding: utf-8 -*-
+
+block_cipher = None
+
+import os
+import whois
+
+# 获取whois数据目录路径
+whois_path = os.path.dirname(whois.__file__)
+whois_data_path = os.path.join(whois_path, 'data')
+
+a = Analysis(
+    ['main.py'],
+    pathex=[],
+    binaries=[],
+    datas=[
+        ('data/a.csv', 'data'),
+        ('data/b.csv', 'data'),
+        (whois_data_path, 'whois/data'),  # 包含whois数据目录
+    ],
+    hiddenimports=[],
+    hookspath=[],
+    hooksconfig={},
+    runtime_hooks=[],
+    excludes=[],
+    win_no_prefer_redirects=False,
+    win_private_assemblies=False,
+    cipher=block_cipher,
+    noarchive=False,
+)
+
+pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
+
+exe = EXE(
+    pyz,
+    a.scripts,
+    a.binaries,
+    a.zipfiles,
+    a.datas,
+    [],
+    name='searchdomain_cli',
+    debug=False,
+    bootloader_ignore_signals=False,
+    strip=False,
+    upx=True,
+    upx_exclude=[],
+    runtime_tmpdir=None,
+    console=True,
+    disable_windowed_traceback=False,
+    argv_emulation=False,
+    target_arch=None,
+    codesign_identity=None,
+    entitlements_file=None,
+    icon=None,
+)

+ 8 - 2
searchdomain/db.py

@@ -14,8 +14,14 @@ class File(Db):
 
     def save(self, filePath: str, res: str):
         # super().save(res)
-        with open(filePath,'a+',encoding='utf-8') as file:
-            file.writelines(res+"\n")
+        # 确保目录存在
+        dir_path = os.path.dirname(filePath)
+        if dir_path and not os.path.exists(dir_path):
+            os.makedirs(dir_path, exist_ok=True)
+        # 使用追加模式写入,如果文件不存在会自动创建
+        with open(filePath, 'a+', encoding='utf-8') as file:
+            file.write(res + "\n")
+            file.flush()  # 确保立即写入磁盘
 
 class Mysql(Db):
     '''mysql数据库保存数据库'''

+ 112 - 14
searchdomain/searchdomain.py

@@ -7,11 +7,12 @@ from . import db
 class SearchDomain(object):
     """search avaliable domain and save result"""
 
-    def __init__(self, params: dict, debug=False, export_all=True):
+    def __init__(self, params: dict, debug=False, export_all=True, log_callback=None):
         '''
         初始化
         debug 调试模式
         export_all 是否导出所有域名,默认导出可用域名
+        log_callback 日志回调函数,用于GUI模式下显示日志
         return:
         '''
         super(SearchDomain, self).__init__()
@@ -19,26 +20,75 @@ class SearchDomain(object):
         self.export_all=export_all
         self.input=params["input"]
         self.output=params["output"]
-        if debug == True:
-            logging.basicConfig(level=logging.DEBUG)
+        self.log_callback = log_callback
+        self._output_file_checked = False  # 标记输出文件是否已检查
+        self._cancelled = False  # 取消标志
+        
+        # 获取当前模块的日志记录器
+        self.logger = logging.getLogger(__name__)
+        
+        # 配置日志系统
+        if log_callback:
+            # 如果有日志回调函数,创建自定义处理器
+            # 创建自定义处理器,将日志输出到回调函数
+            class CallbackHandler(logging.Handler):
+                def __init__(self, callback):
+                    super().__init__()
+                    self.callback = callback
+                
+                def emit(self, record):
+                    msg = self.format(record)
+                    if self.callback:
+                        self.callback(msg)
+            
+            self.logger.setLevel(logging.INFO)
+            
+            # 检查是否已经添加了回调处理器,避免重复添加
+            has_callback_handler = any(isinstance(h, CallbackHandler) for h in self.logger.handlers)
+            if not has_callback_handler:
+                callback_handler = CallbackHandler(log_callback)
+                callback_handler.setLevel(logging.INFO)
+                formatter = logging.Formatter('%(message)s')
+                callback_handler.setFormatter(formatter)
+                self.logger.addHandler(callback_handler)
+        elif debug == True:
+            logging.basicConfig(level=logging.DEBUG, format='%(message)s', force=True)
+        else:
+            logging.basicConfig(level=logging.INFO, format='%(message)s', force=True)
+
+    def cancel(self):
+        """取消搜索任务"""
+        self._cancelled = True
+        self.logger.info("搜索任务已取消")
 
     def crawl(self, domain: str, index:int) -> None:
         '''
         检测域名是否可用
         :params domain 域名:
         :return true or false'''
+        # 检查是否已取消
+        if self._cancelled:
+            return
+        
         res = False
         try:
             whi = whois.whois(domain)
             res = False
-            logging.info(str(index) + ": searching domain:"+ domain + " is unavaliable.")
+            self.logger.info(str(index) + ": searching domain:"+ domain + " is unavaliable.")
         except Exception as e:
-            if(str(e).index("No match") == 0):
+            error_str = str(e)
+            # 检查是否是域名未注册的错误
+            if "No match" in error_str or "No match for" in error_str:
                 res = True
-                logging.info(str(index) + ": searching domain:"+ domain +" is avaliable.")
+                self.logger.info(str(index) + ": searching domain:"+ domain +" is avaliable.")
             else:
                 res = False
-                logging.error(e)
+                self.logger.error(f"Error checking {domain}: {error_str}")
+        
+        # 再次检查是否已取消
+        if self._cancelled:
+            return
+            
         if self.export_all:
             self.saveRes(domain, res)
         else:
@@ -48,16 +98,64 @@ class SearchDomain(object):
     def saveRes(self, domain: str, res: bool):
         """ save result to file """
         # db.Mysql().save()
-        db.File().save(os.path.join(self.params["app_path"], self.output), domain + "    " + str(res))
+        output_path = os.path.join(self.params["app_path"], self.output)
+        # 检查输出文件是否存在,不存在则创建并警告(只检查一次)
+        if not self._output_file_checked:
+            if not os.path.exists(output_path):
+                # 确保目录存在
+                dir_path = os.path.dirname(output_path)
+                if dir_path and not os.path.exists(dir_path):
+                    os.makedirs(dir_path, exist_ok=True)
+                # 创建文件
+                with open(output_path, 'w', encoding='utf-8') as f:
+                    pass
+                self.logger.warning(f"警告:输出文件不存在,已创建: {output_path}")
+            self._output_file_checked = True
+        db.File().save(output_path, domain + "    " + str(res))
 
     def run(self):
         '''begin search domain'''
-        with open(os.path.join(self.params["app_path"], self.input), "r", encoding="utf8", errors="ignore") as file:
-            pool = ThreadPoolExecutor(max_workers=5)
-            index = 0
-            for line in file.readlines():
-                index = index + 1
-                pool.submit(self.crawl, line.strip(), index)
+        # 先创建输出文件(如果不存在),在保存结果前创建
+        output_path = os.path.join(self.params["app_path"], self.output)
+        if not os.path.exists(output_path):
+            # 确保目录存在
+            dir_path = os.path.dirname(output_path)
+            if dir_path and not os.path.exists(dir_path):
+                os.makedirs(dir_path, exist_ok=True)
+            # 创建文件
+            with open(output_path, 'w', encoding='utf-8') as f:
+                pass
+            self.logger.warning(f"警告:输出文件不存在,已创建: {output_path}")
+        
+        # 支持 input 为完整路径或相对路径
+        input_path = self.input if os.path.isabs(self.input) else os.path.join(self.params["app_path"], self.input)
+        with open(input_path, "r", encoding="utf8", errors="ignore") as file:
+            with ThreadPoolExecutor(max_workers=5) as pool:
+                index = 0
+                futures = []
+                for line in file.readlines():
+                    # 检查是否已取消
+                    if self._cancelled:
+                        break
+                    domain = line.strip()
+                    if domain:  # 跳过空行
+                        index = index + 1
+                        future = pool.submit(self.crawl, domain, index)
+                        futures.append(future)
+                # 等待所有任务完成,但如果已取消则取消未完成的任务
+                if self._cancelled:
+                    # 取消所有未完成的任务
+                    for future in futures:
+                        future.cancel()
+                    self.logger.info("已取消所有未完成的任务")
+                else:
+                    # 等待所有任务完成
+                    for future in futures:
+                        try:
+                            future.result()
+                        except Exception as e:
+                            if not self._cancelled:
+                                self.logger.error(f"任务执行出错: {str(e)}")
 
 if __name__ == '__main__':
     sd = SearchDomain()