fix: index CW:28 body, validate API contracts, repair broken data pointer
Browse files- Convert CW/1/革命时期的爱情.txt from GB18030 to UTF-8 (203,548 -> 304,547
bytes, 102,538 characters, zero replacement characters, exact round-trip) and
drop its LFS attribute so the text ships as ordinary content.
- build_fulltext_db.py now decodes source TXT with the same strict UTF-8 then
strict GB18030 fallback as app.py. It previously used lenient UTF-8, so CW:28
was indexed as mojibake and its body could not be searched.
- /api/random accepts the canonical plural sources filter and returns 404 for an
unknown source instead of silently returning an unrelated record.
- /api/folders and /api/folders/.../contents return a JSON 404 for an unknown
source, matching /api/search/{source}.
- Unknown /api/* paths return a JSON 404 instead of the SPA HTML with HTTP 200.
- data/sources.json.gz: the LFS object has been absent from the Hub since the
first upload, so the file is rebuilt from the verified source contract in
data/sidebar/global.json and data/search_data.json.gz, and it is excluded from
the generic *.gz LFS rule so it can be stored as ordinary content.
- Corpus integrity now asserts every source TXT is strict UTF-8.
- .gitattributes +4 -1
- CW/1/革命时期的爱情.txt +0 -0
- app.py +32 -3
- build_fulltext_db.py +20 -1
- data/sources.json.gz +0 -0
- tests/test_integration.py +28 -0
- tests/test_search.py +40 -0
|
@@ -34,4 +34,7 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
static/icons/icon-512.png filter=lfs diff=lfs merge=lfs -text
|
| 37 |
-
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
static/icons/icon-512.png filter=lfs diff=lfs merge=lfs -text
|
| 37 |
+
# This 72-byte source list was first committed as an LFS pointer whose object
|
| 38 |
+
# never existed on the Hub, so every push re-created the same broken pointer.
|
| 39 |
+
# Keep it as ordinary content and exclude it from the generic *.gz rule.
|
| 40 |
+
data/sources.json.gz -filter -diff -merge text
|
|
The diff for this file is too large to render.
See raw diff
|
|
|
|
@@ -1637,16 +1637,41 @@ def api_extensions(source: Optional[str] = Query(default=None)):
|
|
| 1637 |
@app.get("/api/folders/{source_slug}")
|
| 1638 |
|
| 1639 |
def api_folders(source_slug: str):
|
|
|
|
|
|
|
| 1640 |
return JSONResponse(cached_payload(("folders", source_slug), lambda: folder_tree_data.get(source_slug, [])))
|
| 1641 |
@app.get("/api/folders/{source_slug}/contents")
|
| 1642 |
|
| 1643 |
def api_folder_contents(source_slug: str, path: str = Query(default="")):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1644 |
return JSONResponse(cached_payload(("folder_contents", source_slug, path), lambda: get_folder_contents(source_slug, path)))
|
| 1645 |
@app.get("/api/random")
|
| 1646 |
|
| 1647 |
-
def api_random(
|
| 1648 |
-
|
| 1649 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1650 |
if not indices:
|
| 1651 |
return JSONResponse({"error": "no record"}, status_code=404)
|
| 1652 |
return JSONResponse(trim_record(records[random.choice(indices)]))
|
|
@@ -1882,4 +1907,8 @@ def render_index_html(rest_of_path: str = "") -> str:
|
|
| 1882 |
@app.get("/{rest_of_path:path}")
|
| 1883 |
|
| 1884 |
async def serve_spa(rest_of_path: str):
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1885 |
return HTMLResponse(render_index_html(rest_of_path))
|
|
|
|
| 1637 |
@app.get("/api/folders/{source_slug}")
|
| 1638 |
|
| 1639 |
def api_folders(source_slug: str):
|
| 1640 |
+
if source_slug not in source_counts:
|
| 1641 |
+
return JSONResponse({"error": "source not found", "folders": []}, status_code=404)
|
| 1642 |
return JSONResponse(cached_payload(("folders", source_slug), lambda: folder_tree_data.get(source_slug, [])))
|
| 1643 |
@app.get("/api/folders/{source_slug}/contents")
|
| 1644 |
|
| 1645 |
def api_folder_contents(source_slug: str, path: str = Query(default="")):
|
| 1646 |
+
if source_slug not in source_counts:
|
| 1647 |
+
return JSONResponse(
|
| 1648 |
+
{"error": "source not found", "folders": [], "files": [], "current_path": path},
|
| 1649 |
+
status_code=404,
|
| 1650 |
+
)
|
| 1651 |
return JSONResponse(cached_payload(("folder_contents", source_slug, path), lambda: get_folder_contents(source_slug, path)))
|
| 1652 |
@app.get("/api/random")
|
| 1653 |
|
| 1654 |
+
def api_random(
|
| 1655 |
+
source: Optional[str] = Query(default=None),
|
| 1656 |
+
sources: Optional[list[str]] = Query(default=None),
|
| 1657 |
+
):
|
| 1658 |
+
# `sources` is the canonical filter, matching the search API. The singular
|
| 1659 |
+
# `source` stays accepted as a compatibility alias for older cached clients.
|
| 1660 |
+
# Previously only `source` was read, so `?sources=NOPE` was silently ignored
|
| 1661 |
+
# and returned an unrelated record with HTTP 200.
|
| 1662 |
+
requested = list(dict.fromkeys(
|
| 1663 |
+
[value for value in ([source] if source else []) if value]
|
| 1664 |
+
+ [value for value in (sources or []) if value]
|
| 1665 |
+
))
|
| 1666 |
+
if requested:
|
| 1667 |
+
missing = [value for value in requested if value not in source_counts]
|
| 1668 |
+
if missing:
|
| 1669 |
+
return JSONResponse({"error": "source not found", "sources": missing}, status_code=404)
|
| 1670 |
+
indices = [
|
| 1671 |
+
index
|
| 1672 |
+
for value in requested
|
| 1673 |
+
for index in source_records_map.get(value, [])
|
| 1674 |
+
]
|
| 1675 |
if not indices:
|
| 1676 |
return JSONResponse({"error": "no record"}, status_code=404)
|
| 1677 |
return JSONResponse(trim_record(records[random.choice(indices)]))
|
|
|
|
| 1907 |
@app.get("/{rest_of_path:path}")
|
| 1908 |
|
| 1909 |
async def serve_spa(rest_of_path: str):
|
| 1910 |
+
# Unknown API paths must not masquerade as successful SPA pages: clients that
|
| 1911 |
+
# expect JSON otherwise parse HTML with HTTP 200 as a valid response.
|
| 1912 |
+
if rest_of_path == "api" or rest_of_path.startswith("api/"):
|
| 1913 |
+
return JSONResponse({"error": "not found", "path": "/" + rest_of_path}, status_code=404)
|
| 1914 |
return HTMLResponse(render_index_html(rest_of_path))
|
|
@@ -19,6 +19,25 @@ LITERAL_PREFIX = "\0literal:"
|
|
| 19 |
def load_json_gz(path: Path):
|
| 20 |
return json.loads(gzip.decompress(path.read_bytes()).decode("utf-8"))
|
| 21 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
def index_tokens(text: str) -> set[str]:
|
| 23 |
tokens = set()
|
| 24 |
normalized = unicodedata.normalize("NFKC", text or "").casefold()
|
|
@@ -154,7 +173,7 @@ def build_source_db(source: str, source_records: list[dict]) -> None:
|
|
| 154 |
if not file_path.exists():
|
| 155 |
missing += 1
|
| 156 |
continue
|
| 157 |
-
text =
|
| 158 |
number = doc_number(doc_id)
|
| 159 |
connection.execute("INSERT INTO documents (doc_id, summary) VALUES (?, ?)", (doc_id, build_summary(text)))
|
| 160 |
connection.execute("INSERT INTO content_fts (rowid, content) VALUES (?, ?)", (number, fts_content(text)))
|
|
|
|
| 19 |
def load_json_gz(path: Path):
|
| 20 |
return json.loads(gzip.decompress(path.read_bytes()).decode("utf-8"))
|
| 21 |
|
| 22 |
+
def decode_source_text(data: bytes) -> str:
|
| 23 |
+
"""Strict UTF-8 first; a complete strict GB18030 decode is the only fallback.
|
| 24 |
+
|
| 25 |
+
Keep this byte-for-byte equivalent to `app.decode_source_text`. One corpus TXT
|
| 26 |
+
(CW/1/革命时期的爱情.txt) is GB18030; lenient UTF-8 silently drops most of its
|
| 27 |
+
bytes, which made that document nearly unsearchable in the published index.
|
| 28 |
+
"""
|
| 29 |
+
try:
|
| 30 |
+
return data.decode("utf-8")
|
| 31 |
+
except UnicodeDecodeError:
|
| 32 |
+
pass
|
| 33 |
+
try:
|
| 34 |
+
return data.decode("gb18030")
|
| 35 |
+
except UnicodeDecodeError:
|
| 36 |
+
return data.decode("utf-8", errors="ignore")
|
| 37 |
+
|
| 38 |
+
def read_source_text(path: Path) -> str:
|
| 39 |
+
return decode_source_text(path.read_bytes())
|
| 40 |
+
|
| 41 |
def index_tokens(text: str) -> set[str]:
|
| 42 |
tokens = set()
|
| 43 |
normalized = unicodedata.normalize("NFKC", text or "").casefold()
|
|
|
|
| 173 |
if not file_path.exists():
|
| 174 |
missing += 1
|
| 175 |
continue
|
| 176 |
+
text = read_source_text(file_path)
|
| 177 |
number = doc_number(doc_id)
|
| 178 |
connection.execute("INSERT INTO documents (doc_id, summary) VALUES (?, ?)", (doc_id, build_summary(text)))
|
| 179 |
connection.execute("INSERT INTO content_fts (rowid, content) VALUES (?, ?)", (number, fts_content(text)))
|
|
Binary files a/data/sources.json.gz and b/data/sources.json.gz differ
|
|
|
|
@@ -475,12 +475,30 @@ class HttpIntegrationTests(unittest.TestCase):
|
|
| 475 |
self.assertEqual(random_doc.status_code, 200)
|
| 476 |
self.assertEqual(random_doc.json()["Source"], "CW")
|
| 477 |
self.assertEqual(self.client.get("/api/random", params={"source": "missing"}).status_code, 404)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 478 |
for route in ("/", "/CCRD", "/CW"):
|
| 479 |
page = self.client.get(route)
|
| 480 |
self.assertEqual(page.status_code, 200)
|
| 481 |
self.assertIn("text/html", page.headers["content-type"])
|
| 482 |
self.assertIn("initial-sidebar-data", page.text)
|
| 483 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 484 |
def test_search_cache_ttl_is_longer_than_burst_idle_gap(self):
|
| 485 |
self.assertGreaterEqual(app.SEARCH_CACHE_TTL_SECONDS, 300)
|
| 486 |
|
|
@@ -523,6 +541,16 @@ class CorpusIntegrityTests(unittest.TestCase):
|
|
| 523 |
if not (Path(app.BASE_DIR) / record["storage_root"] / record["storage_rel_path"]).is_file()
|
| 524 |
]
|
| 525 |
self.assertEqual(missing, [])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 526 |
self.assertEqual(sum(record["source"] == "CCRD" for record in records), 35586)
|
| 527 |
self.assertEqual(sum(record["source"] == "CW" for record in records), 11664)
|
| 528 |
archive_keys = [(record["source"], record["display_rel_path"], record["doc_id"]) for record in records]
|
|
|
|
| 475 |
self.assertEqual(random_doc.status_code, 200)
|
| 476 |
self.assertEqual(random_doc.json()["Source"], "CW")
|
| 477 |
self.assertEqual(self.client.get("/api/random", params={"source": "missing"}).status_code, 404)
|
| 478 |
+
# `sources` is the canonical filter and must not be silently ignored.
|
| 479 |
+
self.assertEqual(self.client.get("/api/random", params={"sources": "missing"}).status_code, 404)
|
| 480 |
+
self.assertEqual(self.client.get("/api/random", params={"sources": ["CW"]}).json()["Source"], "CW")
|
| 481 |
+
self.assertEqual(
|
| 482 |
+
self.client.get("/api/random", params=[("sources", "CCRD"), ("sources", "CW")]).status_code, 200
|
| 483 |
+
)
|
| 484 |
for route in ("/", "/CCRD", "/CW"):
|
| 485 |
page = self.client.get(route)
|
| 486 |
self.assertEqual(page.status_code, 200)
|
| 487 |
self.assertIn("text/html", page.headers["content-type"])
|
| 488 |
self.assertIn("initial-sidebar-data", page.text)
|
| 489 |
|
| 490 |
+
def test_unknown_sources_and_unknown_api_paths_return_json_404(self):
|
| 491 |
+
for path in ("/api/folders/missing", "/api/folders/missing/contents"):
|
| 492 |
+
response = self.client.get(path)
|
| 493 |
+
self.assertEqual(response.status_code, 404, path)
|
| 494 |
+
self.assertIn("application/json", response.headers["content-type"], path)
|
| 495 |
+
self.assertEqual(response.json().get("error"), "source not found", path)
|
| 496 |
+
for path in ("/api/nope", "/api/search", "/api/folders"):
|
| 497 |
+
response = self.client.get(path)
|
| 498 |
+
self.assertEqual(response.status_code, 404, path)
|
| 499 |
+
self.assertIn("application/json", response.headers["content-type"], path)
|
| 500 |
+
self.assertEqual(response.json().get("error"), "not found", path)
|
| 501 |
+
|
| 502 |
def test_search_cache_ttl_is_longer_than_burst_idle_gap(self):
|
| 503 |
self.assertGreaterEqual(app.SEARCH_CACHE_TTL_SECONDS, 300)
|
| 504 |
|
|
|
|
| 541 |
if not (Path(app.BASE_DIR) / record["storage_root"] / record["storage_rel_path"]).is_file()
|
| 542 |
]
|
| 543 |
self.assertEqual(missing, [])
|
| 544 |
+
# Every source TXT must be strict UTF-8 after the CW:28 conversion. A GB18030
|
| 545 |
+
# file here would be indexed by the builder only through its fallback path.
|
| 546 |
+
non_utf8 = []
|
| 547 |
+
for record in records:
|
| 548 |
+
path = Path(app.BASE_DIR) / record["storage_root"] / record["storage_rel_path"]
|
| 549 |
+
try:
|
| 550 |
+
path.read_bytes().decode("utf-8")
|
| 551 |
+
except UnicodeDecodeError:
|
| 552 |
+
non_utf8.append(record["doc_id"])
|
| 553 |
+
self.assertEqual(non_utf8, [])
|
| 554 |
self.assertEqual(sum(record["source"] == "CCRD" for record in records), 35586)
|
| 555 |
self.assertEqual(sum(record["source"] == "CW" for record in records), 11664)
|
| 556 |
archive_keys = [(record["source"], record["display_rel_path"], record["doc_id"]) for record in records]
|
|
@@ -93,6 +93,46 @@ class SourceDecodingTests(unittest.TestCase):
|
|
| 93 |
# Neither strict decoder accepts this; keep the old lenient behaviour.
|
| 94 |
self.assertEqual(app.decode_source_text(b"ok\x81\xff"), "ok")
|
| 95 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
|
| 97 |
class MetadataSearchTests(unittest.TestCase):
|
| 98 |
def setUp(self):
|
|
|
|
| 93 |
# Neither strict decoder accepts this; keep the old lenient behaviour.
|
| 94 |
self.assertEqual(app.decode_source_text(b"ok\x81\xff"), "ok")
|
| 95 |
|
| 96 |
+
def test_builder_and_runtime_decode_source_text_identically(self):
|
| 97 |
+
samples = [
|
| 98 |
+
"手机\n文化".encode("utf-8"),
|
| 99 |
+
" 《革命时期的爱情(序)》\r\n这是一本关于性爱的书。".encode("gb18030"),
|
| 100 |
+
b"ok\x81\xff",
|
| 101 |
+
b"",
|
| 102 |
+
]
|
| 103 |
+
for data in samples:
|
| 104 |
+
self.assertEqual(
|
| 105 |
+
build_fulltext_db.decode_source_text(data),
|
| 106 |
+
app.decode_source_text(data),
|
| 107 |
+
data[:20],
|
| 108 |
+
)
|
| 109 |
+
|
| 110 |
+
def test_builder_indexes_gb18030_source_instead_of_dropping_it(self):
|
| 111 |
+
with tempfile.TemporaryDirectory() as directory:
|
| 112 |
+
root = Path(directory)
|
| 113 |
+
legacy = " 《革命时期的爱情(序)》\r\n这是一本关于性爱的书。".encode("gb18030")
|
| 114 |
+
source = root / "CW" / "1" / "革命时期的爱情.txt"
|
| 115 |
+
source.parent.mkdir(parents=True)
|
| 116 |
+
source.write_bytes(legacy)
|
| 117 |
+
records = [{
|
| 118 |
+
"doc_id": "CW:28", "source": "CW", "storage_root": "CW",
|
| 119 |
+
"storage_rel_path": "1/革命时期的爱情.txt",
|
| 120 |
+
}]
|
| 121 |
+
original_dir = build_fulltext_db.FULLTEXT_DIR
|
| 122 |
+
build_fulltext_db.FULLTEXT_DIR = root / "data" / "fulltext"
|
| 123 |
+
build_fulltext_db.FULLTEXT_DIR.mkdir(parents=True)
|
| 124 |
+
try:
|
| 125 |
+
with patch.object(build_fulltext_db, "BASE_DIR", root):
|
| 126 |
+
build_fulltext_db.build_source_db("CW", records)
|
| 127 |
+
with sqlite3.connect(build_fulltext_db.FULLTEXT_DIR / "CW.sqlite3") as connection:
|
| 128 |
+
content = connection.execute("SELECT content FROM content_fts").fetchone()[0]
|
| 129 |
+
summary = connection.execute("SELECT summary FROM documents").fetchone()[0]
|
| 130 |
+
self.assertIn("革命时期的爱情", content)
|
| 131 |
+
self.assertIn("性爱", content)
|
| 132 |
+
self.assertIn("性爱", summary)
|
| 133 |
+
finally:
|
| 134 |
+
build_fulltext_db.FULLTEXT_DIR = original_dir
|
| 135 |
+
|
| 136 |
|
| 137 |
class MetadataSearchTests(unittest.TestCase):
|
| 138 |
def setUp(self):
|