opencode commited on
Commit
fe038ce
·
1 Parent(s): 3f38b03

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 CHANGED
@@ -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
- CW/1/革命时期的爱情.txt 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
+ # 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
CW/1/革命时期的爱情.txt CHANGED
The diff for this file is too large to render. See raw diff
 
app.py CHANGED
@@ -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(source: Optional[str] = Query(default=None)):
1648
- if source:
1649
- indices = source_records_map.get(source, [])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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))
build_fulltext_db.py CHANGED
@@ -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 = file_path.read_text(encoding="utf-8", errors="ignore")
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)))
data/sources.json.gz CHANGED
Binary files a/data/sources.json.gz and b/data/sources.json.gz differ
 
tests/test_integration.py CHANGED
@@ -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]
tests/test_search.py CHANGED
@@ -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):