"""Webスクレイピングツール（requests + BeautifulSoup4）

【スクレイピング前に必ず確認すること】
  1. 利用規約: 対象サイトが自動取得（スクレイピング・クローリング）を
     禁止していないか。禁止されていれば robots.txt が許可していても
     行わない（規約は運営者との契約で、robots.txt より上位）。
  2. robots.txt: 対象パスが Disallow されていないか。本アプリは取得前に
     urllib.robotparser で自動判定し、Disallow のときは取得を中止する。
  3. アクセス間隔: 連続取得の間隔を空けてサーバーに負荷をかけない。
     本アプリは既定 3 秒以上、robots.txt に Crawl-delay があればその
     秒数以上を待ってから次のリクエストを送る。
  4. User-Agent: ブラウザのふりをせず素性を名乗る。下の USER_AGENT を
     自分の名前・連絡先に必ず書き換えてから使うこと。
  取得したデータの複製・再配布には著作権法上の制限があります。判断に
  迷うときは取得しないのが安全です。
"""
import tkinter as tk
from tkinter import ttk, messagebox, filedialog
import csv
import json
import threading
import time
import urllib.parse
import urllib.robotparser

import requests
from bs4 import BeautifulSoup

# 既定URLは自サイト。第三者サイトを既定値にしない（誤って負荷をかけないため）
DEFAULT_URL = "https://pythonland.tech/libraries.html"
# ツール名・URL・連絡先の3つを必ず自分のものに書き換える（ブラウザのふりをしない）
USER_AGENT = ("MyScrapingTool/1.0 "
              "(+https://example.com/your-page; contact: you@example.com)")
MIN_INTERVAL_SEC = 3.0   # 連続リクエストの最小間隔
TIMEOUT_SEC = 10
MAX_PREVIEW_CHARS = 100000
MAX_RESULTS = 2000


class App068:
    """Webスクレイピングツール"""

    def __init__(self, root):
        self.root = root
        self.root.title("Webスクレイピングツール")
        self.root.geometry("1100x680")
        self.root.configure(bg="#0d1117")
        self._results = []
        self._raw_html = ""
        self._soup = None
        self._base_url = ""
        self._last_request_at = 0.0
        self._fetching = False
        self._build_ui()

    def _build_ui(self):
        header = tk.Frame(self.root, bg="#161b22", pady=6)
        header.pack(fill=tk.X)
        tk.Label(header, text="🕷 Webスクレイピングツール",
                 font=("Noto Sans JP", 12, "bold"),
                 bg="#161b22", fg="#f0f6fc").pack(side=tk.LEFT, padx=12)
        tk.Label(header, text="規約とrobots.txtを確認してから取得します",
                 font=("Arial", 9), bg="#161b22",
                 fg="#8b949e").pack(side=tk.LEFT, padx=8)

        # URL入力
        url_f = tk.Frame(self.root, bg="#0d1117", pady=4)
        url_f.pack(fill=tk.X, padx=8)
        tk.Label(url_f, text="URL:", bg="#0d1117", fg="#c9d1d9",
                 font=("Arial", 9)).pack(side=tk.LEFT)
        self.url_var = tk.StringVar(value=DEFAULT_URL)
        ttk.Entry(url_f, textvariable=self.url_var,
                  width=55).pack(side=tk.LEFT, padx=4)
        ttk.Button(url_f, text="▶ 取得",
                   command=self._fetch).pack(side=tk.LEFT)
        self.robots_var = tk.BooleanVar(value=True)
        tk.Checkbutton(url_f, text="robots.txt を確認する",
                       variable=self.robots_var, bg="#0d1117", fg="#c9d1d9",
                       selectcolor="#161b22", activebackground="#0d1117",
                       activeforeground="#c9d1d9",
                       font=("Arial", 9)).pack(side=tk.LEFT, padx=6)

        # 抽出設定
        extract_f = tk.LabelFrame(self.root, text="抽出設定",
                                  bg="#161b22", fg="#c9d1d9",
                                  font=("Arial", 9), padx=8, pady=4)
        extract_f.pack(fill=tk.X, padx=8, pady=4)

        tk.Label(extract_f, text="モード:", bg="#161b22", fg="#c9d1d9",
                 font=("Arial", 9)).grid(row=0, column=0, sticky="w")
        self.mode_var = tk.StringVar(value="links")
        modes = [("タイトル", "title"), ("リンク", "links"),
                 ("見出し", "headings"), ("テーブル", "tables"),
                 ("テキスト", "text"), ("メタタグ", "meta"),
                 ("CSSセレクタ", "css")]
        for i, (label, val) in enumerate(modes):
            tk.Radiobutton(extract_f, text=label, variable=self.mode_var,
                           value=val, bg="#161b22", fg="#c9d1d9",
                           selectcolor="#0d1117",
                           activebackground="#161b22").grid(
                row=0, column=i + 1, padx=4)

        tk.Label(extract_f, text="CSSセレクタ:", bg="#161b22", fg="#c9d1d9",
                 font=("Arial", 9)).grid(row=1, column=0, sticky="w", pady=2)
        self.css_var = tk.StringVar(value="h2")
        ttk.Entry(extract_f, textvariable=self.css_var,
                  width=30).grid(row=1, column=1, columnspan=3,
                                 sticky="w", padx=4)
        tk.Label(extract_f, text="属性:", bg="#161b22", fg="#c9d1d9",
                 font=("Arial", 9)).grid(row=1, column=4, sticky="w")
        self.attr_var = tk.StringVar(value="")
        ttk.Entry(extract_f, textvariable=self.attr_var,
                  width=10).grid(row=1, column=5, sticky="w")
        ttk.Button(extract_f, text="🔍 抽出",
                   command=self._extract).grid(row=1, column=6, padx=8)

        # 結果エリア
        paned = ttk.PanedWindow(self.root, orient=tk.HORIZONTAL)
        paned.pack(fill=tk.BOTH, expand=True, padx=4, pady=4)

        # 左: 結果リスト
        left = tk.Frame(paned, bg="#0d1117")
        paned.add(left, weight=1)
        tk.Label(left, text="抽出結果", bg="#0d1117", fg="#8b949e",
                 font=("Arial", 9)).pack(anchor="w")
        self.result_list = tk.Listbox(
            left, bg="#161b22", fg="#c9d1d9",
            selectbackground="#1f6feb", font=("Arial", 9),
            relief=tk.FLAT, activestyle="none")
        lsb = ttk.Scrollbar(left, command=self.result_list.yview)
        self.result_list.configure(yscrollcommand=lsb.set)
        lsb.pack(side=tk.RIGHT, fill=tk.Y)
        self.result_list.pack(fill=tk.BOTH, expand=True)
        self.result_list.bind("<<ListboxSelect>>", self._on_result_select)

        btn_f = tk.Frame(left, bg="#0d1117", pady=2)
        btn_f.pack(fill=tk.X)
        ttk.Button(btn_f, text="📋 コピー",
                   command=self._copy).pack(side=tk.LEFT, padx=2)
        ttk.Button(btn_f, text="💾 CSV保存",
                   command=self._save_csv).pack(side=tk.LEFT, padx=2)
        ttk.Button(btn_f, text="JSON保存",
                   command=self._save_json).pack(side=tk.LEFT, padx=2)

        # 右: HTMLプレビュー
        right = tk.Frame(paned, bg="#0d1117")
        paned.add(right, weight=1)
        tk.Label(right, text="HTML ソース", bg="#0d1117", fg="#8b949e",
                 font=("Arial", 9)).pack(anchor="w")
        self.html_text = tk.Text(right, bg="#161b22", fg="#6e7681",
                                 font=("Courier New", 8), relief=tk.FLAT,
                                 wrap=tk.NONE, state=tk.DISABLED)
        hxsb = ttk.Scrollbar(right, orient=tk.HORIZONTAL,
                             command=self.html_text.xview)
        hysb = ttk.Scrollbar(right, command=self.html_text.yview)
        self.html_text.configure(xscrollcommand=hxsb.set,
                                 yscrollcommand=hysb.set)
        hysb.pack(side=tk.RIGHT, fill=tk.Y)
        self.html_text.pack(fill=tk.BOTH, expand=True)
        hxsb.pack(fill=tk.X)

        self.progress = ttk.Progressbar(self.root, mode="indeterminate")
        self.progress.pack(fill=tk.X, padx=8)
        self.status_var = tk.StringVar(
            value="利用規約を確認したURLを入力して「取得」してください")
        tk.Label(self.root, textvariable=self.status_var,
                 bg="#21262d", fg="#8b949e", font=("Arial", 9),
                 anchor="w", padx=8).pack(fill=tk.X, side=tk.BOTTOM)

    # ------------------------------------------------------------------
    # 取得（ネットワーク）
    # ------------------------------------------------------------------
    def _fetch(self):
        """「▶ 取得」ボタン。入力を検証してワーカースレッドを起動する。"""
        url = self.url_var.get().strip()
        parsed = urllib.parse.urlparse(url)
        if parsed.scheme not in ("http", "https") or not parsed.netloc:
            messagebox.showerror(
                "エラー", "http:// または https:// で始まるURLを入力してください")
            return
        if self._fetching:
            self.status_var.set("取得中です。完了までお待ちください")
            return
        self._fetching = True
        self.progress.start()
        self.status_var.set("確認中: robots.txt")
        threading.Thread(target=self._do_fetch, args=(url,),
                         daemon=True).start()

    def _check_robots(self, url):
        """robots.txt を読んで (取得可否, Crawl-delay, 説明) を返す。"""
        parts = urllib.parse.urlparse(url)
        robots_url = f"{parts.scheme}://{parts.netloc}/robots.txt"
        parser = urllib.robotparser.RobotFileParser()
        parser.set_url(robots_url)
        try:
            parser.read()
        except Exception as e:
            # 読めなかったときは「許可された」とみなさない
            return False, None, f"robots.txt を確認できませんでした（{e}）"
        if not parser.can_fetch(USER_AGENT, url):
            return False, None, "robots.txt が Disallow のため中止しました"
        delay = parser.crawl_delay(USER_AGENT)
        return True, delay, "robots.txt: 許可"

    def _wait_interval(self, crawl_delay):
        """前回リクエストからの間隔を空ける（サーバー負荷対策）。"""
        need = MIN_INTERVAL_SEC
        if crawl_delay:
            need = max(need, float(crawl_delay))
        elapsed = time.monotonic() - self._last_request_at
        if self._last_request_at and elapsed < need:
            time.sleep(need - elapsed)

    def _do_fetch(self, url):
        """ワーカースレッド本体。UI 更新は after() でメインスレッドに戻す。"""
        try:
            note = "robots.txt: 未確認（利用規約は自分で確認すること）"
            crawl_delay = None
            if self.robots_var.get():
                allowed, crawl_delay, note = self._check_robots(url)
                if not allowed:
                    self.root.after(0, self._on_fetch_error, note)
                    return
            self._wait_interval(crawl_delay)
            self._last_request_at = time.monotonic()
            res = requests.get(url, headers={"User-Agent": USER_AGENT},
                               timeout=TIMEOUT_SEC)
            res.raise_for_status()
            if not res.encoding or "charset" not in \
                    res.headers.get("content-type", "").lower():
                res.encoding = res.apparent_encoding or res.encoding
            self.root.after(0, self._on_fetched, res.text, url, note)
        except Exception as e:
            self.root.after(0, self._on_fetch_error, f"取得エラー: {e}")

    def _on_fetched(self, html_text, url, note):
        """取得したHTMLを BeautifulSoup で解析し、プレビューに表示する。"""
        self._fetching = False
        self.progress.stop()
        self._raw_html = html_text
        self._base_url = url
        self._soup = BeautifulSoup(html_text, "html.parser")
        self.html_text.configure(state=tk.NORMAL)
        self.html_text.delete("1.0", tk.END)
        self.html_text.insert("1.0", html_text[:MAX_PREVIEW_CHARS])
        self.html_text.configure(state=tk.DISABLED)
        self.status_var.set(
            f"取得完了: {len(html_text):,} 文字 / {note}")
        self._extract()

    def _on_fetch_error(self, message):
        self._fetching = False
        self.progress.stop()
        self.status_var.set(message)

    # ------------------------------------------------------------------
    # 抽出（BeautifulSoup）
    # ------------------------------------------------------------------
    def _extract(self):
        """選択中のモードで解析結果を取り出し、リストに表示する。"""
        if self._soup is None:
            messagebox.showwarning("警告", "先にHTMLを取得してください")
            return
        mode = self.mode_var.get()
        extractors = {
            "title": self._extract_title,
            "links": self._extract_links,
            "headings": self._extract_headings,
            "tables": self._extract_tables,
            "text": self._extract_text,
            "meta": self._extract_meta,
            "css": self._extract_css,
        }
        try:
            results = extractors[mode]()
        except Exception as e:
            messagebox.showerror("エラー", f"抽出に失敗しました: {e}")
            return
        self._results = results[:MAX_RESULTS]
        self.result_list.delete(0, tk.END)
        for kind, value in self._results:
            self.result_list.insert(tk.END, f"[{kind}] {value[:120]}")
        note = ""
        if len(results) > MAX_RESULTS:
            note = f"（全 {len(results)} 件のうち先頭 {MAX_RESULTS} 件を表示）"
        self.status_var.set(
            f"抽出完了: {len(self._results)} 件（モード: {mode}）{note}")

    def _extract_title(self):
        """<title> と代表的なメタ情報を取り出す。"""
        results = []
        if self._soup.title:
            results.append(("title", self._soup.title.get_text(strip=True)))
        desc = self._soup.find("meta", attrs={"name": "description"})
        if desc and desc.get("content"):
            results.append(("description", desc["content"]))
        h1 = self._soup.find("h1")
        if h1:
            results.append(("h1", h1.get_text(strip=True)))
        return results

    def _extract_links(self):
        """<a href> を絶対URLに直して取り出す。"""
        results = []
        for a in self._soup.find_all("a", href=True):
            href = urllib.parse.urljoin(self._base_url, a["href"])
            text = a.get_text(strip=True) or "(テキストなし)"
            results.append((text[:40], href))
        return results

    def _extract_headings(self):
        """h1〜h6 を文書順に取り出す。"""
        results = []
        for tag in self._soup.find_all(["h1", "h2", "h3", "h4", "h5", "h6"]):
            text = tag.get_text(strip=True)
            if text:
                results.append((tag.name.upper(), text))
        return results

    def _extract_tables(self):
        """<table> の行をセル区切りのテキストにして取り出す。"""
        results = []
        for t_no, table in enumerate(self._soup.find_all("table"), start=1):
            for row in table.find_all("tr"):
                cells = [c.get_text(" ", strip=True)
                         for c in row.find_all(["th", "td"])]
                line = " | ".join(cells).strip()
                if line:
                    results.append((f"表{t_no}", line))
        return results

    def _extract_text(self):
        """script・style を除いた本文テキストを行単位で取り出す。"""
        soup = BeautifulSoup(self._raw_html, "html.parser")
        for tag in soup(["script", "style", "noscript"]):
            tag.decompose()
        text = soup.get_text("\n", strip=True)
        return [("text", line) for line in text.split("\n") if line.strip()]

    def _extract_meta(self):
        """<meta> の name / property と content を取り出す。"""
        results = []
        for m in self._soup.find_all("meta"):
            key = m.get("name") or m.get("property") or m.get("http-equiv")
            if key and m.get("content"):
                results.append((key, m["content"]))
            elif m.get("charset"):
                results.append(("charset", m["charset"]))
        return results

    def _extract_css(self):
        """CSSセレクタで要素を選び、テキストまたは属性値を取り出す。"""
        selector = self.css_var.get().strip()
        if not selector:
            return [("エラー", "CSSセレクタを入力してください")]
        attr = self.attr_var.get().strip()
        results = []
        for el in self._soup.select(selector):
            if attr:
                if el.has_attr(attr):
                    results.append((f"{el.name}[{attr}]", str(el[attr])))
            else:
                text = el.get_text(" ", strip=True)
                if text:
                    results.append((el.name, text))
        return results

    # ------------------------------------------------------------------
    # 結果の利用
    # ------------------------------------------------------------------
    def _on_result_select(self, event=None):
        """選択行の全文をステータスバーに表示する（一覧は120文字で省略）。"""
        sel = self.result_list.curselection()
        if not sel or sel[0] >= len(self._results):
            return
        kind, value = self._results[sel[0]]
        self.status_var.set(f"[{kind}] {value}")

    def _copy(self):
        text = "\n".join(f"{k}\t{v}" for k, v in self._results)
        self.root.clipboard_clear()
        self.root.clipboard_append(text)
        self.status_var.set(f"{len(self._results)} 件をコピーしました")

    def _save_csv(self):
        path = filedialog.asksaveasfilename(
            defaultextension=".csv", filetypes=[("CSV", "*.csv")])
        if not path:
            return
        try:
            with open(path, "w", newline="", encoding="utf-8-sig") as f:
                writer = csv.writer(f)
                writer.writerow(["種別", "内容"])
                writer.writerows(self._results)
            messagebox.showinfo("完了", f"保存: {path}")
        except Exception as e:
            messagebox.showerror("エラー", str(e))

    def _save_json(self):
        path = filedialog.asksaveasfilename(
            defaultextension=".json", filetypes=[("JSON", "*.json")])
        if not path:
            return
        try:
            with open(path, "w", encoding="utf-8") as f:
                json.dump({"url": self._base_url or self.url_var.get(),
                           "mode": self.mode_var.get(),
                           "results": [{"kind": k, "value": v}
                                       for k, v in self._results]},
                          f, ensure_ascii=False, indent=2)
            messagebox.showinfo("完了", f"保存: {path}")
        except Exception as e:
            messagebox.showerror("エラー", str(e))


if __name__ == "__main__":
    root = tk.Tk()
    app = App068(root)
    root.mainloop()
