File size: 19,582 Bytes
c018d8c
348843b
a82ca2e
077e11b
147425b
348843b
077e11b
db803bc
 
 
fdaf6da
da6ccca
348843b
4ad1cab
077e11b
348843b
 
077e11b
 
29fdcc8
a82ca2e
fdaf6da
 
 
 
 
348843b
177c471
 
 
348843b
147425b
348843b
fdaf6da
 
 
 
 
 
a82ca2e
 
 
 
 
 
 
 
 
 
 
 
fdaf6da
a82ca2e
 
 
29fdcc8
 
 
6f52f9c
a82ca2e
348843b
6f52f9c
58d3278
a82ca2e
58d3278
 
 
348843b
 
 
30d446f
29fdcc8
 
 
 
 
a82ca2e
 
 
29fdcc8
 
a82ca2e
 
 
 
 
 
6e056fc
348843b
29fdcc8
 
 
 
 
a82ca2e
348843b
 
147425b
a82ca2e
 
fdaf6da
a82ca2e
 
29fdcc8
 
 
 
 
 
a82ca2e
 
348843b
4ad1cab
 
 
29fdcc8
 
4ad1cab
 
 
 
 
6f52f9c
29fdcc8
 
 
6f52f9c
 
29fdcc8
 
 
 
 
 
 
 
 
 
 
 
 
6f52f9c
4ad1cab
fdaf6da
 
 
c018d8c
 
 
 
 
 
 
 
fdaf6da
 
 
 
 
 
 
 
 
 
 
 
 
 
c018d8c
29fdcc8
fdaf6da
 
 
 
 
4ad1cab
c018d8c
db803bc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a578b52
348843b
 
a578b52
 
348843b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
da6ccca
 
077e11b
 
 
 
 
 
 
348843b
da6ccca
 
 
 
 
ac10e6a
 
 
 
 
 
 
 
077e11b
ac10e6a
 
 
35891af
 
 
 
 
 
077e11b
ac10e6a
 
 
 
 
 
35891af
 
 
 
 
 
 
 
 
 
 
 
 
 
 
077e11b
 
ac10e6a
 
 
 
35891af
 
 
 
 
 
 
 
 
 
 
077e11b
35891af
ac10e6a
 
 
 
 
 
 
 
 
 
 
af027b9
 
 
 
ac10e6a
 
a82ca2e
 
 
 
4ad1cab
 
 
 
348843b
 
 
 
 
a578b52
 
 
348843b
a578b52
46fc1c6
348843b
a82ca2e
 
348843b
a82ca2e
 
 
 
 
348843b
 
 
a578b52
348843b
 
ac10e6a
348843b
 
 
af027b9
077e11b
a578b52
077e11b
 
348843b
 
 
 
 
af027b9
 
 
348843b
077e11b
 
af027b9
077e11b
ac10e6a
 
 
35891af
 
 
 
 
 
 
077e11b
ac10e6a
077e11b
ac10e6a
a578b52
077e11b
348843b
e695b45
348843b
fdaf6da
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
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
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
import hashlib
import json
import os
import re
import shutil
import subprocess
import unicodedata
import urllib.error
import urllib.parse
import urllib.request
from concurrent.futures import ThreadPoolExecutor
from functools import lru_cache
from pathlib import Path
from typing import Any, Callable, Iterator, Iterable
import jieba
from .config import ARCHIVE_END, ARCHIVE_START, PARSED_ROOT, REPO_PREFIX

CJK_RE = re.compile(r"[\u3400-\u4dbf\u4e00-\u9fff]+")
SEARCH_RUN_RE = re.compile(r"[a-z0-9]+|[\u3400-\u4dbf\u4e00-\u9fff]+", re.IGNORECASE)
DOCUMENT_BUILD_VERSION = "1"
PARSED_CACHE_ROOT = Path(os.environ["BHA_PARSED_CACHE_ROOT"]) if os.environ.get("BHA_PARSED_CACHE_ROOT") else None
try:
    REMOTE_GIT_TIMEOUT_SECONDS = max(1, int(os.environ.get("BHA_REMOTE_GIT_TIMEOUT_SECONDS", "30")))
except ValueError:
    REMOTE_GIT_TIMEOUT_SECONDS = 30
REMOTE_GIT_WORKERS = 8

def initialize_search_tokenizer() -> None:
    jieba.initialize()

def run(command: list[str], cwd: Path | None = None) -> None:
    subprocess.run(command, cwd=str(cwd) if cwd else None, check=True)

def link_or_copy(source: str | os.PathLike[str], destination: str | os.PathLike[str]) -> None:
    try:
        os.link(source, destination)
    except OSError:
        shutil.copy2(source, destination)

def cache_parsed_repository(source: Path, archive_id: int) -> None:
    if PARSED_CACHE_ROOT is None:
        return
    PARSED_CACHE_ROOT.mkdir(parents=True, exist_ok=True)
    target = PARSED_CACHE_ROOT / f"archives{archive_id}"
    if target.is_dir() and (target / ".git").is_dir():
        source_head = subprocess.run(["git", "rev-parse", "HEAD"], cwd=source, capture_output=True, text=True, check=True).stdout.strip()
        cached_head = subprocess.run(["git", "rev-parse", "HEAD"], cwd=target, capture_output=True, text=True, check=True).stdout.strip()
        if source_head == cached_head:
            return
    temporary = PARSED_CACHE_ROOT / f".archives{archive_id}.{os.getpid()}.tmp"
    shutil.rmtree(temporary, ignore_errors=True)
    shutil.copytree(source, temporary, copy_function=link_or_copy)
    shutil.rmtree(target, ignore_errors=True)
    temporary.replace(target)

def ensure_parsed_data(
    progress: Callable[[int, int], None] | None = None,
    expected_commits: dict[int, str] | None = None,
    archive_ids: Iterable[int] | None = None,
) -> dict[int, set[str]] | None:
    PARSED_ROOT.mkdir(parents=True, exist_ok=True)
    archive_ids = list(archive_ids) if archive_ids is not None else list(range(ARCHIVE_START, ARCHIVE_END + 1))
    total = len(archive_ids)
    changed_paths: dict[int, set[str]] | None = {} if expected_commits is not None else None
    for position, archive_id in enumerate(archive_ids, start=1):
        if progress:
            progress(position, total)
        target = PARSED_ROOT / f"archives{archive_id}"
        repo = f"{REPO_PREFIX}/banned-historical-archives{archive_id}.git"
        if (target / ".git").exists():
            run(["git", "remote", "set-url", "origin", repo], target)
            expected = expected_commits.get(archive_id) if expected_commits else None
            current = subprocess.run(
                ["git", "rev-parse", "HEAD"], cwd=target, check=True, capture_output=True, text=True,
            ).stdout.strip()
            if expected and current == expected:
                if changed_paths is not None:
                    changed_paths[archive_id] = set()
                cache_parsed_repository(target, archive_id)
                continue
            run(["git", "fetch", "--depth", "1", "origin", expected or "parsed"], target)
            if expected:
                diff = subprocess.run(
                    ["git", "diff", "--name-only", current, "FETCH_HEAD"],
                    cwd=target, check=True, capture_output=True, text=True,
                ).stdout.splitlines()
                changed_paths[archive_id] = {path for path in diff if path}
            run(["git", "clean", "-fdx"], target)
            run(["git", "checkout", "parsed"], target)
            run(["git", "reset", "--hard", "FETCH_HEAD"], target)
            if expected and subprocess.run(
                ["git", "rev-parse", "HEAD"], cwd=target, check=True, capture_output=True, text=True,
            ).stdout.strip() != expected:
                raise RuntimeError(f"parsed commit mismatch for archive {archive_id}")
            cache_parsed_repository(target, archive_id)
            continue
        if target.exists():
            shutil.rmtree(target)
        cached = PARSED_CACHE_ROOT / f"archives{archive_id}" if PARSED_CACHE_ROOT is not None else None
        if cached is not None and cached.is_dir() and (cached / ".git").exists():
            shutil.copytree(cached, target, copy_function=link_or_copy)
        else:
            run(["git", "clone", "--depth", "1", "--branch", "parsed", repo, str(target)])
        expected = expected_commits.get(archive_id) if expected_commits else None
        if expected and subprocess.run(
            ["git", "rev-parse", "HEAD"], cwd=target, check=True, capture_output=True, text=True,
        ).stdout.strip() != expected:
            run(["git", "fetch", "--depth", "1", "origin", expected], target)
            run(["git", "reset", "--hard", "FETCH_HEAD"], target)
        cache_parsed_repository(target, archive_id)
    return changed_paths

def parsed_corpus_fingerprint(
    remote: bool = False, revisions: dict[int, str] | None = None
) -> str:
    if remote:
        raise RuntimeError("remote parsed tree fingerprints require checked-out data")
    revisions = revisions or parsed_archive_revisions(remote=remote)
    return hashlib.sha256(
        "\n".join(f"{archive_id}:{revisions[archive_id]}" for archive_id in sorted(revisions)).encode()
    ).hexdigest()

def parsed_archive_revisions(remote: bool = False, archive_ids: Iterable[int] | None = None) -> dict[int, str]:
    if remote:
        raise RuntimeError("remote parsed tree revisions require checked-out data")
    revisions: dict[int, str] = {}
    selected = archive_ids if archive_ids is not None else range(ARCHIVE_START, ARCHIVE_END + 1)
    for archive_id in selected:
        result = subprocess.run(
            ["git", "rev-parse", "HEAD^{tree}"],
            cwd=PARSED_ROOT / f"archives{archive_id}",
            check=True,
            capture_output=True,
            text=True,
        )
        revision = result.stdout.strip()
        if not re.fullmatch(r"[0-9a-f]{40}", revision):
            raise RuntimeError(f"invalid parsed revision for archive {archive_id}")
        revisions[archive_id] = revision
    return revisions

def parsed_archive_commits(remote: bool = False, archive_ids: Iterable[int] | None = None) -> dict[int, str]:
    revisions: dict[int, str] = {}
    selected = list(archive_ids) if archive_ids is not None else list(range(ARCHIVE_START, ARCHIVE_END + 1))
    if not remote:
        for archive_id in selected:
            result = subprocess.run(
                ["git", "rev-parse", "HEAD"],
                cwd=PARSED_ROOT / f"archives{archive_id}",
                check=True,
                capture_output=True,
                text=True,
            )
            revision = result.stdout.strip()
            if not re.fullmatch(r"[0-9a-f]{40}", revision):
                raise RuntimeError(f"invalid parsed commit for archive {archive_id}")
            revisions[archive_id] = revision
        return revisions

    def fetch_remote(archive_id: int) -> tuple[int, str]:
        result = subprocess.run(
            ["git", "ls-remote", f"{REPO_PREFIX}/banned-historical-archives{archive_id}.git", "refs/heads/parsed"],
            check=True,
            capture_output=True,
            text=True,
            timeout=REMOTE_GIT_TIMEOUT_SECONDS,
        )
        revision = result.stdout.split(maxsplit=1)[0]
        if not re.fullmatch(r"[0-9a-f]{40}", revision):
            raise RuntimeError(f"invalid parsed commit for archive {archive_id}")
        return archive_id, revision

    with ThreadPoolExecutor(max_workers=min(REMOTE_GIT_WORKERS, max(1, len(selected)))) as executor:
        for archive_id, revision in executor.map(fetch_remote, selected):
            revisions[archive_id] = revision
    return revisions

def parsed_archive_tree_revisions(
    commits: dict[int, str], archive_ids: Iterable[int] | None = None,
) -> dict[int, str]:
    """Read tree SHAs for remote commits without downloading repository data."""
    selected = list(archive_ids) if archive_ids is not None else list(commits)
    parsed = urllib.parse.urlparse(REPO_PREFIX)
    parts = [part for part in parsed.path.split("/") if part]
    if parsed.netloc != "github.com" or len(parts) != 1:
        raise RuntimeError("remote tree lookup requires a github.com repository prefix")
    owner = urllib.parse.quote(parts[0], safe="")

    def fetch_tree(archive_id: int) -> tuple[int, str]:
        repo = urllib.parse.quote(f"banned-historical-archives{archive_id}", safe="")
        commit = commits[archive_id]
        url = f"https://api.github.com/repos/{owner}/{repo}/git/commits/{commit}"
        request = urllib.request.Request(url, headers={
            "Accept": "application/vnd.github+json",
            "X-GitHub-Api-Version": "2022-11-28",
            "User-Agent": "bha-search-source-check/1.0",
        })
        try:
            with urllib.request.urlopen(request, timeout=REMOTE_GIT_TIMEOUT_SECONDS) as response:
                payload = json.load(response)
        except urllib.error.HTTPError as exc:
            raise RuntimeError(f"remote tree lookup failed for archive {archive_id}: HTTP {exc.code}") from exc
        tree = str(payload.get("tree", {}).get("sha") or "")
        if not re.fullmatch(r"[0-9a-f]{40}", tree):
            raise RuntimeError(f"invalid remote tree revision for archive {archive_id}")
        return archive_id, tree

    with ThreadPoolExecutor(max_workers=min(REMOTE_GIT_WORKERS, max(1, len(selected)))) as executor:
        return dict(executor.map(fetch_tree, selected))

def read_json(path: Path) -> Any:
    try:
        return json.loads(path.read_text(encoding="utf-8"))
    except Exception as exc:
        raise ValueError(f"failed to parse JSON: {path}") from exc

def text_from_article(article: dict[str, Any]) -> str:
    parts = article.get("parts") or []
    comments = article.get("comments") or []
    values: list[str] = []
    if article.get("description"):
        values.append(str(article.get("description")))
    for part in parts:
        if isinstance(part, dict) and part.get("text"):
            values.append(str(part.get("text")))
    for comment in comments:
        if comment:
            values.append(str(comment))
    return "\n".join(values)

@lru_cache(maxsize=512)
def _search_words(text: str) -> str:
    tokens: list[str] = []
    for run in SEARCH_RUN_RE.findall(text):
        if CJK_RE.fullmatch(run):
            tokens.extend(token.strip() for token in jieba.lcut(run) if token.strip())
        else:
            tokens.append(run)
    return " ".join(tokens)


def search_words(value: Any) -> str:
    text = unicodedata.normalize("NFKC", str(value or "")).casefold()
    return _search_words(text)

def normalize_date_value(item: dict[str, Any]) -> int | None:
    year = item.get("year")
    if not isinstance(year, int):
        return None
    month = item.get("month") if isinstance(item.get("month"), int) else 1
    day = item.get("day") if isinstance(item.get("day"), int) else 1
    return year * 10000 + month * 100 + day

def normalize_dates(article: dict[str, Any]) -> tuple[int | None, int | None, list[str], list[int], list[int], list[int], list[int], list[str], list[str], list[str], list[str]]:
    raw_dates = article.get("dates") if isinstance(article.get("dates"), list) else []
    values: list[int] = []
    displays: list[str] = []
    years: set[int] = set()
    months: set[int] = set()
    days: set[int] = set()
    month_days: set[str] = set()
    year_months: set[str] = set()
    year_days: set[str] = set()
    year_month_days: set[str] = set()
    for item in raw_dates:
        if not isinstance(item, dict):
            continue
        value = normalize_date_value(item)
        if value is not None:
            values.append(value)
        year_value = item.get("year")
        month_value = item.get("month")
        day_value = item.get("day")
        if isinstance(year_value, int):
            years.add(year_value)
        if isinstance(month_value, int):
            months.add(month_value)
        if isinstance(day_value, int):
            days.add(day_value)
        if isinstance(month_value, int) and isinstance(day_value, int):
            month_days.add(f"{month_value:02d}-{day_value:02d}")
        if isinstance(year_value, int) and isinstance(month_value, int):
            year_months.add(f"{year_value:04d}-{month_value:02d}")
        if isinstance(year_value, int) and isinstance(day_value, int):
            year_days.add(f"{year_value:04d}-{day_value:02d}")
        if isinstance(year_value, int) and isinstance(month_value, int) and isinstance(day_value, int):
            year_month_days.add(f"{year_value:04d}-{month_value:02d}-{day_value:02d}")
        year = item.get("year") if item.get("year") is not None else "----"
        month = item.get("month") if item.get("month") is not None else "--"
        day = item.get("day") if item.get("day") is not None else "--"
        displays.append(f"{year}/{month}/{day}")
    return (
        min(values) if values else None,
        max(values) if values else None,
        displays,
        sorted(set(values)),
        sorted(years),
        sorted(months),
        sorted(days),
        sorted(month_days),
        sorted(year_months),
        sorted(year_days),
        sorted(year_month_days),
    )

def normalize_tags(tags: Any) -> tuple[list[str], list[str]]:
    if not isinstance(tags, list):
        return [], []
    names: list[str] = []
    types: list[str] = []
    for item in tags:
        if not isinstance(item, dict):
            continue
        name = item.get("name")
        tag_type = item.get("type")
        if name is not None and str(name).strip():
            names.append(str(name).strip())
        if tag_type is not None and str(tag_type).strip():
            types.append(str(tag_type).strip())
    return names, types

def iter_documents(
    archive_ids: Iterable[int] | None = None,
    changed_paths: dict[int, set[str]] | None = None,
) -> Iterator[dict[str, Any]]:
    selected_archives = (
        sorted(set(archive_ids)) if archive_ids is not None else range(ARCHIVE_START, ARCHIVE_END + 1)
    )
    for archive_id in selected_archives:
        archive_root = PARSED_ROOT / f"archives{archive_id}"
        if not archive_root.exists():
            continue
        for metadata_path in archive_root.glob("*/*/*.metadata"):
            publication_id = metadata_path.stem
            publication = read_json(metadata_path)
            if not isinstance(publication, dict):
                raise ValueError(f"publication metadata must be an object: {metadata_path}")
            publication_name = str(publication.get("name") or publication_id)
            publication_name_words = search_words(publication_name)
            publication_files = publication.get("files") if isinstance(publication.get("files"), list) else []
            book_root = metadata_path.parent
            metadata_relative = metadata_path.relative_to(archive_root).as_posix()
            archive_changed = changed_paths.get(archive_id) if changed_paths is not None else None
            for article_path in book_root.glob("*/*.json"):
                if archive_changed is not None:
                    article_relative = article_path.relative_to(archive_root).as_posix()
                    tags_relative = article_path.with_suffix(".tags").relative_to(archive_root).as_posix()
                    if metadata_relative not in archive_changed and article_relative not in archive_changed and tags_relative not in archive_changed:
                        continue
                article_id = article_path.stem
                article = read_json(article_path)
                if not isinstance(article, dict):
                    raise ValueError(f"article must be an object: {article_path}")
                tags_path = article_path.with_suffix(".tags")
                tags = read_json(tags_path) if tags_path.exists() else []
                tag_names, tag_types = normalize_tags(tags)
                authors = article.get("authors") or []
                if not isinstance(authors, list):
                    authors = []
                authors = [str(item).strip() for item in authors if str(item).strip()]
                date_min, date_max, date_display, dates, date_years, date_months, date_days, date_month_days, date_year_months, date_year_days, date_year_month_days = normalize_dates(article)
                doc_id = f"{archive_id}:{len(article_id)}:{article_id}:{publication_id}"
                title = str(article.get("title") or article_id)
                content = text_from_article(article)
                yield {
                    "doc_id": doc_id,
                    "article_id": article_id,
                    "publication_id": publication_id,
                    "publication_name": publication_name,
                    "publication_author": str(publication.get("author") or "").strip(),
                    "publication_type": str(publication.get("type") or "").strip(),
                    "source_files": [str(item).strip() for item in publication_files if str(item).strip()],
                    "archive_id": archive_id,
                    "title": title,
                    "title_words": search_words(title),
                    "authors": authors,
                    "authors_words": search_words(" ".join(authors)),
                    "date_min": date_min,
                    "date_max": date_max,
                    "date_display": date_display,
                    "dates": dates,
                    "date_years": date_years,
                    "date_months": date_months,
                    "date_days": date_days,
                    "date_month_days": date_month_days,
                    "date_year_months": date_year_months,
                    "date_year_days": date_year_days,
                    "date_year_month_days": date_year_month_days,
                    "tag_names": tag_names,
                    "tag_names_words": search_words(" ".join(tag_names)),
                    "tag_types": tag_types,
                    "publication_name_words": publication_name_words,
                    "content": content,
                    "path": str(article_path.relative_to(archive_root)),
                    "article": {**article, "tags": tags},
                }

def iter_document_ids(archive_ids: Iterable[int] | None = None) -> Iterator[str]:
    selected_archives = (
        sorted(set(archive_ids)) if archive_ids is not None else range(ARCHIVE_START, ARCHIVE_END + 1)
    )
    for archive_id in selected_archives:
        archive_root = PARSED_ROOT / f"archives{archive_id}"
        if not archive_root.exists():
            continue
        for metadata_path in archive_root.glob("*/*/*.metadata"):
            publication_id = metadata_path.stem
            for article_path in metadata_path.parent.glob("*/*.json"):
                article_id = article_path.stem
                yield f"{archive_id}:{len(article_id)}:{article_id}:{publication_id}"